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};
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_duplicate_exports, 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            let files: Vec<std::path::PathBuf> = cycle
352                .iter()
353                .map(|&id| graph.modules[id.0 as usize].path.clone())
354                .collect();
355            let length = files.len();
356            let (line, col) =
357                cycle_edge_line_col(graph, line_offsets_map, &cycle, 0).unwrap_or((1, 0));
358            Some(CircularDependency {
359                files,
360                length,
361                line,
362                col,
363                is_cross_package: false,
364            })
365        })
366        .collect();
367
368    if !workspaces.is_empty() {
369        for dep in &mut dependencies {
370            dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
371        }
372    }
373
374    dependencies
375}
376
377/// Thin wrapper around [`find_circular_dependencies`] that gates on
378/// `Severity::Off` and wraps the bare results in typed envelopes.
379/// Extracted from the rayon-join tree to keep nesting under the clippy
380/// `excessive_nesting` threshold (7).
381fn run_circular_dep_detector(
382    graph: &ModuleGraph,
383    config: &ResolvedConfig,
384    line_offsets_by_file: &LineOffsetsMap<'_>,
385    suppressions: &crate::suppress::SuppressionContext<'_>,
386    workspaces: &[fallow_config::WorkspaceInfo],
387) -> Vec<CircularDependencyFinding> {
388    if config.rules.circular_dependencies == Severity::Off {
389        return Vec::new();
390    }
391    find_circular_dependencies(graph, line_offsets_by_file, suppressions, workspaces)
392        .into_iter()
393        .map(CircularDependencyFinding::with_actions)
394        .collect()
395}
396
397/// Thin wrapper around [`re_export_cycles::find_re_export_cycles`] that gates
398/// on `Severity::Off`. Extracted alongside [`run_circular_dep_detector`].
399fn run_re_export_cycle_detector(
400    graph: &ModuleGraph,
401    config: &ResolvedConfig,
402    suppressions: &crate::suppress::SuppressionContext<'_>,
403) -> Vec<ReExportCycleFinding> {
404    if config.rules.re_export_cycle == Severity::Off {
405        return Vec::new();
406    }
407    find_re_export_cycles(graph, suppressions)
408}
409
410/// Collect export usage counts for Code Lens (LSP feature). Skipped in CLI
411/// mode since the field is `#[serde(skip)]` in all output formats.
412fn run_export_usages_collector(
413    graph: &ModuleGraph,
414    line_offsets_by_file: &LineOffsetsMap<'_>,
415    collect_usages: bool,
416) -> Vec<crate::results::ExportUsage> {
417    if collect_usages {
418        collect_export_usages(graph, line_offsets_by_file)
419    } else {
420        Vec::new()
421    }
422}
423
424/// Collect every package name declared across the root `package.json` and each
425/// workspace `package.json`. This is the dependency universe the plugin system
426/// activates on, reused by the framework-scoped security catalogue rows (#861) to
427/// gate a row on the active framework. Missing or malformed manifests contribute
428/// nothing (a framework row simply stays inert), matching the conservative
429/// false-negatives-over-false-positives posture.
430fn collect_declared_dependency_names(
431    config: &ResolvedConfig,
432    root_pkg: Option<&PackageJson>,
433    workspaces: &[fallow_config::WorkspaceInfo],
434) -> FxHashSet<String> {
435    let mut deps: FxHashSet<String> = FxHashSet::default();
436    if let Some(pkg) = root_pkg {
437        deps.extend(pkg.all_dependency_names());
438    }
439    for ws in workspaces {
440        if ws.root == config.root {
441            continue; // already covered by root_pkg
442        }
443        if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
444            deps.extend(pkg.all_dependency_names());
445        }
446    }
447    deps
448}
449
450/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
451#[expect(
452    deprecated,
453    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
454)]
455#[deprecated(
456    since = "2.76.0",
457    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."
458)]
459#[expect(
460    clippy::too_many_lines,
461    reason = "orchestration function calling all detectors; each call is one-line and the sequence is easier to follow in one place"
462)]
463pub fn find_dead_code_full(
464    graph: &ModuleGraph,
465    config: &ResolvedConfig,
466    resolved_modules: &[ResolvedModule],
467    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
468    workspaces: &[fallow_config::WorkspaceInfo],
469    modules: &[ModuleInfo],
470    collect_usages: bool,
471) -> AnalysisResults {
472    let _span = tracing::info_span!("find_dead_code").entered();
473
474    let suppressions = crate::suppress::SuppressionContext::new(modules);
475
476    let line_offsets_by_file: LineOffsetsMap<'_> = modules
477        .iter()
478        .filter(|m| !m.line_offsets.is_empty())
479        .map(|m| (m.file_id, m.line_offsets.as_slice()))
480        .collect();
481
482    let pkg_path = config.root.join("package.json");
483    let pkg = PackageJson::load(&pkg_path).ok();
484    let public_api_entry_points =
485        public_api_package_entry_points(graph, config, pkg.as_ref(), workspaces);
486
487    let iconify_referenced =
488        iconify::collect_iconify_referenced_deps(modules, pkg.as_ref(), workspaces);
489    let augmented_plugin_result;
490    let plugin_result = if iconify_referenced.is_empty() {
491        plugin_result
492    } else {
493        let mut owned = plugin_result.cloned().unwrap_or_default();
494        owned.referenced_dependencies.extend(iconify_referenced);
495        augmented_plugin_result = owned;
496        Some(&augmented_plugin_result)
497    };
498
499    let mut user_class_members = config.used_class_members.clone();
500    if let Some(plugin_result) = plugin_result {
501        user_class_members.extend(plugin_result.used_class_members.iter().cloned());
502    }
503
504    let virtual_prefixes: Vec<&str> = plugin_result
505        .map(|pr| {
506            pr.virtual_module_prefixes
507                .iter()
508                .map(String::as_str)
509                .collect()
510        })
511        .unwrap_or_default();
512    let generated_patterns: Vec<&str> = plugin_result
513        .map(|pr| {
514            pr.generated_import_patterns
515                .iter()
516                .map(String::as_str)
517                .collect()
518        })
519        .unwrap_or_default();
520    let generated_type_prefixes: Vec<&str> = plugin_result
521        .map(|pr| {
522            pr.generated_type_import_prefixes
523                .iter()
524                .map(String::as_str)
525                .collect()
526        })
527        .unwrap_or_default();
528
529    let (
530        (unused_files, export_results),
531        (
532            (member_results, dependency_results),
533            (
534                (unresolved_imports, duplicate_exports),
535                (boundary_violations, (circular_dependencies, (re_export_cycles, export_usages))),
536            ),
537        ),
538    ) = rayon::join(
539        || {
540            rayon::join(
541                || {
542                    if config.rules.unused_files != Severity::Off {
543                        find_unused_files(graph, &suppressions)
544                            .into_iter()
545                            .map(UnusedFileFinding::with_actions)
546                            .collect::<Vec<_>>()
547                    } else {
548                        Vec::new()
549                    }
550                },
551                || {
552                    let mut results = AnalysisResults::default();
553                    if config.rules.unused_exports != Severity::Off
554                        || config.rules.unused_types != Severity::Off
555                        || config.rules.private_type_leaks != Severity::Off
556                    {
557                        let (exports, types, stale_expected) = find_unused_exports(
558                            graph,
559                            modules,
560                            config,
561                            plugin_result,
562                            &suppressions,
563                            &line_offsets_by_file,
564                        );
565                        if config.rules.unused_exports != Severity::Off {
566                            results.unused_exports = exports
567                                .into_iter()
568                                .map(UnusedExportFinding::with_actions)
569                                .collect();
570                        }
571                        if config.rules.unused_types != Severity::Off {
572                            let mut typed = types;
573                            suppress_signature_backing_types(&mut typed, graph, modules);
574                            results.unused_types = typed
575                                .into_iter()
576                                .map(UnusedTypeFinding::with_actions)
577                                .collect();
578                        }
579                        if config.rules.private_type_leaks != Severity::Off {
580                            results.private_type_leaks = find_private_type_leaks(
581                                graph,
582                                modules,
583                                config,
584                                &suppressions,
585                                &line_offsets_by_file,
586                            )
587                            .into_iter()
588                            .map(PrivateTypeLeakFinding::with_actions)
589                            .collect();
590                        }
591                        if config.rules.stale_suppressions != Severity::Off {
592                            results.stale_suppressions.extend(stale_expected);
593                        }
594                    }
595                    results
596                },
597            )
598        },
599        || {
600            rayon::join(
601                || {
602                    rayon::join(
603                        || {
604                            let mut results = AnalysisResults::default();
605                            if config.rules.unused_enum_members != Severity::Off
606                                || config.rules.unused_class_members != Severity::Off
607                            {
608                                let (enum_members, class_members) =
609                                    find_unused_members_with_public_api_entry_points(
610                                        graph,
611                                        resolved_modules,
612                                        modules,
613                                        &suppressions,
614                                        &line_offsets_by_file,
615                                        &user_class_members,
616                                        &config.ignore_decorators,
617                                        &public_api_entry_points,
618                                    );
619                                if config.rules.unused_enum_members != Severity::Off {
620                                    results.unused_enum_members = enum_members
621                                        .into_iter()
622                                        .map(UnusedEnumMemberFinding::with_actions)
623                                        .collect();
624                                }
625                                if config.rules.unused_class_members != Severity::Off {
626                                    results.unused_class_members = class_members
627                                        .into_iter()
628                                        .map(UnusedClassMemberFinding::with_actions)
629                                        .collect();
630                                }
631                            }
632                            results
633                        },
634                        || {
635                            let mut results = AnalysisResults::default();
636                            if let Some(ref pkg) = pkg {
637                                if config.rules.unused_dependencies != Severity::Off
638                                    || config.rules.unused_dev_dependencies != Severity::Off
639                                    || config.rules.unused_optional_dependencies != Severity::Off
640                                {
641                                    let (deps, dev_deps, optional_deps) = find_unused_dependencies(
642                                        graph,
643                                        pkg,
644                                        config,
645                                        plugin_result,
646                                        workspaces,
647                                    );
648                                    if config.rules.unused_dependencies != Severity::Off {
649                                        results.unused_dependencies = deps
650                                            .into_iter()
651                                            .map(UnusedDependencyFinding::with_actions)
652                                            .collect();
653                                    }
654                                    if config.rules.unused_dev_dependencies != Severity::Off {
655                                        results.unused_dev_dependencies = dev_deps
656                                            .into_iter()
657                                            .map(UnusedDevDependencyFinding::with_actions)
658                                            .collect();
659                                    }
660                                    if config.rules.unused_optional_dependencies != Severity::Off {
661                                        results.unused_optional_dependencies = optional_deps
662                                            .into_iter()
663                                            .map(UnusedOptionalDependencyFinding::with_actions)
664                                            .collect();
665                                    }
666                                }
667
668                                if config.rules.unlisted_dependencies != Severity::Off {
669                                    results.unlisted_dependencies = find_unlisted_dependencies(
670                                        graph,
671                                        pkg,
672                                        config,
673                                        workspaces,
674                                        plugin_result,
675                                        resolved_modules,
676                                        &line_offsets_by_file,
677                                    )
678                                    .into_iter()
679                                    .map(UnlistedDependencyFinding::with_actions)
680                                    .collect();
681                                }
682
683                                if config.production {
684                                    results.type_only_dependencies =
685                                        find_type_only_dependencies(graph, pkg, config, workspaces)
686                                            .into_iter()
687                                            .map(TypeOnlyDependencyFinding::with_actions)
688                                            .collect();
689                                }
690
691                                if !config.production
692                                    && config.rules.test_only_dependencies != Severity::Off
693                                {
694                                    results.test_only_dependencies =
695                                        find_test_only_dependencies(graph, pkg, config, workspaces)
696                                            .into_iter()
697                                            .map(TestOnlyDependencyFinding::with_actions)
698                                            .collect();
699                                }
700                            }
701                            results
702                        },
703                    )
704                },
705                || {
706                    rayon::join(
707                        || {
708                            rayon::join(
709                                || {
710                                    if config.rules.unresolved_imports != Severity::Off
711                                        && !resolved_modules.is_empty()
712                                    {
713                                        find_unresolved_imports(
714                                            resolved_modules,
715                                            config,
716                                            &suppressions,
717                                            &virtual_prefixes,
718                                            &generated_patterns,
719                                            &generated_type_prefixes,
720                                            &line_offsets_by_file,
721                                        )
722                                        .into_iter()
723                                        .map(UnresolvedImportFinding::with_actions)
724                                        .collect::<Vec<_>>()
725                                    } else {
726                                        Vec::new()
727                                    }
728                                },
729                                || {
730                                    if config.rules.duplicate_exports != Severity::Off {
731                                        find_duplicate_exports(
732                                            graph,
733                                            config,
734                                            &suppressions,
735                                            &line_offsets_by_file,
736                                            resolved_modules,
737                                        )
738                                        .into_iter()
739                                        .map(DuplicateExportFinding::with_actions)
740                                        .collect::<Vec<_>>()
741                                    } else {
742                                        Vec::new()
743                                    }
744                                },
745                            )
746                        },
747                        || {
748                            rayon::join(
749                                || {
750                                    if config.rules.boundary_violation != Severity::Off
751                                        && !config.boundaries.is_empty()
752                                    {
753                                        boundary::find_boundary_violations(
754                                            graph,
755                                            config,
756                                            &suppressions,
757                                            &line_offsets_by_file,
758                                        )
759                                        .into_iter()
760                                        .map(BoundaryViolationFinding::with_actions)
761                                        .collect::<Vec<_>>()
762                                    } else {
763                                        Vec::new()
764                                    }
765                                },
766                                || {
767                                    rayon::join(
768                                        || {
769                                            run_circular_dep_detector(
770                                                graph,
771                                                config,
772                                                &line_offsets_by_file,
773                                                &suppressions,
774                                                workspaces,
775                                            )
776                                        },
777                                        || {
778                                            rayon::join(
779                                                || {
780                                                    run_re_export_cycle_detector(
781                                                        graph,
782                                                        config,
783                                                        &suppressions,
784                                                    )
785                                                },
786                                                || {
787                                                    run_export_usages_collector(
788                                                        graph,
789                                                        &line_offsets_by_file,
790                                                        collect_usages,
791                                                    )
792                                                },
793                                            )
794                                        },
795                                    )
796                                },
797                            )
798                        },
799                    )
800                },
801            )
802        },
803    );
804
805    let mut results = AnalysisResults {
806        unused_files,
807        unused_exports: export_results.unused_exports,
808        unused_types: export_results.unused_types,
809        private_type_leaks: export_results.private_type_leaks,
810        stale_suppressions: export_results.stale_suppressions,
811        unused_enum_members: member_results.unused_enum_members,
812        unused_class_members: member_results.unused_class_members,
813        unused_dependencies: dependency_results.unused_dependencies,
814        unused_dev_dependencies: dependency_results.unused_dev_dependencies,
815        unused_optional_dependencies: dependency_results.unused_optional_dependencies,
816        unlisted_dependencies: dependency_results.unlisted_dependencies,
817        type_only_dependencies: dependency_results.type_only_dependencies,
818        test_only_dependencies: dependency_results.test_only_dependencies,
819        unresolved_imports,
820        duplicate_exports,
821        boundary_violations,
822        circular_dependencies,
823        re_export_cycles,
824        export_usages,
825        ..AnalysisResults::default()
826    };
827
828    let public_roots = public_workspace_roots(&config.public_packages, workspaces);
829    if !public_roots.is_empty() {
830        results.unused_exports.retain(|e| {
831            !public_roots
832                .iter()
833                .any(|root| e.export.path.starts_with(root))
834        });
835        results.unused_types.retain(|e| {
836            !public_roots
837                .iter()
838                .any(|root| e.export.path.starts_with(root))
839        });
840        results.unused_enum_members.retain(|e| {
841            !public_roots
842                .iter()
843                .any(|root| e.member.path.starts_with(root))
844        });
845        results.unused_class_members.retain(|e| {
846            !public_roots
847                .iter()
848                .any(|root| e.member.path.starts_with(root))
849        });
850    }
851
852    if config.rules.security_client_server_leak != Severity::Off {
853        let (security_findings, stats) =
854            security::find_security_findings(graph, modules, &suppressions, &line_offsets_by_file);
855        results.security_findings = security_findings;
856        results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
857    }
858
859    if config.rules.security_sink != Severity::Off {
860        let categories = config.security.categories.as_ref();
861        let filter = security::CategoryFilter::new(
862            categories.and_then(|c| c.include.clone()),
863            categories.and_then(|c| c.exclude.clone()),
864        );
865        // Framework-scoped catalogue rows (#861) gate on the active framework via
866        // the project's declared dependency set: the same dependency universe the
867        // plugin system activates on (root package.json + every workspace
868        // package.json). Built once here and passed to the detector.
869        let declared_deps = collect_declared_dependency_names(config, pkg.as_ref(), workspaces);
870        let (sink_findings, sink_stats) = security::find_tainted_sinks(
871            graph,
872            modules,
873            &suppressions,
874            &line_offsets_by_file,
875            &filter,
876            &declared_deps,
877            &config.root,
878        );
879        results.security_findings.extend(sink_findings);
880        results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
881    }
882
883    // Reachability-weighted ranking (issue #860): order security candidates so
884    // those reachable from a runtime/application entry point with a wider
885    // blast radius surface above isolated helpers/scripts. Reuses the existing
886    // graph reachability + reverse-dep fan-in; pairs optionally with boundary
887    // crossings already computed this run. Pure graph-side glue + output order.
888    if !results.security_findings.is_empty() {
889        let boundary_anchor_paths: rustc_hash::FxHashSet<std::path::PathBuf> = results
890            .boundary_violations
891            .iter()
892            .flat_map(|b| [b.violation.from_path.clone(), b.violation.to_path.clone()])
893            .collect();
894        security::rank_security_findings(
895            graph,
896            &boundary_anchor_paths,
897            &mut results.security_findings,
898        );
899    }
900
901    if config.rules.stale_suppressions != Severity::Off {
902        results
903            .stale_suppressions
904            .extend(suppressions.find_stale(graph, config));
905    }
906    results.suppression_count = suppressions.used_count();
907    results.active_suppressions = suppressions.all_suppressions(graph);
908
909    let need_unused_catalogs = config.rules.unused_catalog_entries != Severity::Off;
910    let need_empty_catalog_groups = config.rules.empty_catalog_groups != Severity::Off;
911    let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
912    if (need_unused_catalogs || need_empty_catalog_groups || need_unresolved_refs)
913        && let Some(state) = gather_pnpm_catalog_state(config, workspaces)
914    {
915        if need_unused_catalogs {
916            results.unused_catalog_entries = find_unused_catalog_entries(&state)
917                .into_iter()
918                .map(UnusedCatalogEntryFinding::with_actions)
919                .collect();
920        }
921        if need_empty_catalog_groups {
922            results.empty_catalog_groups = find_empty_catalog_groups(&state)
923                .into_iter()
924                .map(EmptyCatalogGroupFinding::with_actions)
925                .collect();
926        }
927        if need_unresolved_refs {
928            results.unresolved_catalog_references = find_unresolved_catalog_references(
929                &state,
930                &config.compiled_ignore_catalog_references,
931                &config.root,
932            )
933            .into_iter()
934            .map(UnresolvedCatalogReferenceFinding::with_actions)
935            .collect();
936        }
937    }
938
939    let need_unused_overrides = config.rules.unused_dependency_overrides != Severity::Off;
940    let need_misconfigured_overrides =
941        config.rules.misconfigured_dependency_overrides != Severity::Off;
942    if (need_unused_overrides || need_misconfigured_overrides)
943        && let Some(state) = gather_pnpm_override_state(config, workspaces)
944    {
945        if need_unused_overrides {
946            results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
947                .into_iter()
948                .map(UnusedDependencyOverrideFinding::with_actions)
949                .collect();
950        }
951        if need_misconfigured_overrides {
952            results.misconfigured_dependency_overrides =
953                find_misconfigured_dependency_overrides(&state, config)
954                    .into_iter()
955                    .map(MisconfiguredDependencyOverrideFinding::with_actions)
956                    .collect();
957        }
958    }
959
960    results.sort();
961
962    results
963}
964
965#[cfg(test)]
966#[expect(
967    deprecated,
968    reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
969)]
970mod tests {
971    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
972
973    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
974        let offsets = compute_line_offsets(source);
975        byte_offset_to_line_col(&offsets, byte_offset)
976    }
977
978    #[test]
979    fn compute_offsets_empty() {
980        assert_eq!(compute_line_offsets(""), vec![0]);
981    }
982
983    #[test]
984    fn compute_offsets_single_line() {
985        assert_eq!(compute_line_offsets("hello"), vec![0]);
986    }
987
988    #[test]
989    fn compute_offsets_multiline() {
990        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
991    }
992
993    #[test]
994    fn compute_offsets_trailing_newline() {
995        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
996    }
997
998    #[test]
999    fn compute_offsets_crlf() {
1000        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
1001    }
1002
1003    #[test]
1004    fn compute_offsets_consecutive_newlines() {
1005        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
1006    }
1007
1008    #[test]
1009    fn byte_offset_empty_source() {
1010        assert_eq!(line_col("", 0), (1, 0));
1011    }
1012
1013    #[test]
1014    fn byte_offset_single_line_start() {
1015        assert_eq!(line_col("hello", 0), (1, 0));
1016    }
1017
1018    #[test]
1019    fn byte_offset_single_line_middle() {
1020        assert_eq!(line_col("hello", 4), (1, 4));
1021    }
1022
1023    #[test]
1024    fn byte_offset_multiline_start_of_line2() {
1025        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
1026    }
1027
1028    #[test]
1029    fn byte_offset_multiline_middle_of_line3() {
1030        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
1031    }
1032
1033    #[test]
1034    fn byte_offset_at_newline_boundary() {
1035        assert_eq!(line_col("line1\nline2", 5), (1, 5));
1036    }
1037
1038    #[test]
1039    fn byte_offset_multibyte_utf8() {
1040        let source = "hi\n\u{1F600}x";
1041        assert_eq!(line_col(source, 3), (2, 0));
1042        assert_eq!(line_col(source, 7), (2, 4));
1043    }
1044
1045    #[test]
1046    fn byte_offset_multibyte_accented_chars() {
1047        let source = "caf\u{00E9}\nbar";
1048        assert_eq!(line_col(source, 6), (2, 0));
1049        assert_eq!(line_col(source, 3), (1, 3));
1050    }
1051
1052    #[test]
1053    fn byte_offset_via_map_fallback() {
1054        use super::*;
1055        let map: LineOffsetsMap<'_> = FxHashMap::default();
1056        assert_eq!(
1057            super::byte_offset_to_line_col(&map, FileId(99), 42),
1058            (1, 42)
1059        );
1060    }
1061
1062    #[test]
1063    fn byte_offset_via_map_lookup() {
1064        use super::*;
1065        let offsets = compute_line_offsets("abc\ndef\nghi");
1066        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1067        map.insert(FileId(0), &offsets);
1068        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1069    }
1070
1071    mod orchestration {
1072        use super::super::*;
1073        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1074        use std::path::PathBuf;
1075
1076        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1077            find_dead_code_full(graph, config, &[], None, &[], &[], false)
1078        }
1079
1080        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1081            FallowConfig {
1082                rules,
1083                ..Default::default()
1084            }
1085            .resolve(
1086                PathBuf::from("/tmp/orchestration-test"),
1087                OutputFormat::Human,
1088                1,
1089                true,
1090                true,
1091                None,
1092            )
1093        }
1094
1095        #[test]
1096        fn find_dead_code_all_rules_off_returns_empty() {
1097            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1098            use crate::graph::ModuleGraph;
1099            use crate::resolve::ResolvedModule;
1100            use rustc_hash::FxHashSet;
1101
1102            let files = vec![DiscoveredFile {
1103                id: FileId(0),
1104                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1105                size_bytes: 100,
1106            }];
1107            let entry_points = vec![EntryPoint {
1108                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1109                source: EntryPointSource::ManualEntry,
1110            }];
1111            let resolved = vec![ResolvedModule {
1112                file_id: FileId(0),
1113                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1114                exports: vec![],
1115                re_exports: vec![],
1116                resolved_imports: vec![],
1117                resolved_dynamic_imports: vec![],
1118                resolved_dynamic_patterns: vec![],
1119                member_accesses: vec![],
1120                whole_object_uses: vec![],
1121                has_cjs_exports: false,
1122                has_angular_component_template_url: false,
1123                unused_import_bindings: FxHashSet::default(),
1124                type_referenced_import_bindings: vec![],
1125                value_referenced_import_bindings: vec![],
1126                namespace_object_aliases: vec![],
1127            }];
1128            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1129
1130            let rules = RulesConfig {
1131                unused_files: Severity::Off,
1132                unused_exports: Severity::Off,
1133                unused_types: Severity::Off,
1134                private_type_leaks: Severity::Off,
1135                unused_dependencies: Severity::Off,
1136                unused_dev_dependencies: Severity::Off,
1137                unused_optional_dependencies: Severity::Off,
1138                unused_enum_members: Severity::Off,
1139                unused_class_members: Severity::Off,
1140                unresolved_imports: Severity::Off,
1141                unlisted_dependencies: Severity::Off,
1142                duplicate_exports: Severity::Off,
1143                type_only_dependencies: Severity::Off,
1144                circular_dependencies: Severity::Off,
1145                re_export_cycle: Severity::Off,
1146                test_only_dependencies: Severity::Off,
1147                boundary_violation: Severity::Off,
1148                coverage_gaps: Severity::Off,
1149                feature_flags: Severity::Off,
1150                stale_suppressions: Severity::Off,
1151                unused_catalog_entries: Severity::Off,
1152                empty_catalog_groups: Severity::Off,
1153                unresolved_catalog_references: Severity::Off,
1154                unused_dependency_overrides: Severity::Off,
1155                misconfigured_dependency_overrides: Severity::Off,
1156                security_client_server_leak: Severity::Off,
1157                security_sink: Severity::Off,
1158            };
1159            let config = make_config_with_rules(rules);
1160            let results = find_dead_code(&graph, &config);
1161
1162            assert!(results.unused_files.is_empty());
1163            assert!(results.unused_exports.is_empty());
1164            assert!(results.unused_types.is_empty());
1165            assert!(results.unused_dependencies.is_empty());
1166            assert!(results.unused_dev_dependencies.is_empty());
1167            assert!(results.unused_optional_dependencies.is_empty());
1168            assert!(results.unused_enum_members.is_empty());
1169            assert!(results.unused_class_members.is_empty());
1170            assert!(results.unresolved_imports.is_empty());
1171            assert!(results.unlisted_dependencies.is_empty());
1172            assert!(results.duplicate_exports.is_empty());
1173            assert!(results.circular_dependencies.is_empty());
1174            assert!(results.export_usages.is_empty());
1175        }
1176
1177        #[test]
1178        fn find_dead_code_full_collect_usages_flag() {
1179            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1180            use crate::extract::{ExportName, VisibilityTag};
1181            use crate::graph::{ExportSymbol, ModuleGraph};
1182            use crate::resolve::ResolvedModule;
1183            use oxc_span::Span;
1184            use rustc_hash::FxHashSet;
1185
1186            let files = vec![DiscoveredFile {
1187                id: FileId(0),
1188                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1189                size_bytes: 100,
1190            }];
1191            let entry_points = vec![EntryPoint {
1192                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1193                source: EntryPointSource::ManualEntry,
1194            }];
1195            let resolved = vec![ResolvedModule {
1196                file_id: FileId(0),
1197                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1198                exports: vec![],
1199                re_exports: vec![],
1200                resolved_imports: vec![],
1201                resolved_dynamic_imports: vec![],
1202                resolved_dynamic_patterns: vec![],
1203                member_accesses: vec![],
1204                whole_object_uses: vec![],
1205                has_cjs_exports: false,
1206                has_angular_component_template_url: false,
1207                unused_import_bindings: FxHashSet::default(),
1208                type_referenced_import_bindings: vec![],
1209                value_referenced_import_bindings: vec![],
1210                namespace_object_aliases: vec![],
1211            }];
1212            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1213            graph.modules[0].exports = vec![ExportSymbol {
1214                name: ExportName::Named("myExport".to_string()),
1215                is_type_only: false,
1216                is_side_effect_used: false,
1217                visibility: VisibilityTag::None,
1218                span: Span::new(10, 30),
1219                references: vec![],
1220                members: vec![],
1221            }];
1222
1223            let rules = RulesConfig::default();
1224            let config = make_config_with_rules(rules);
1225
1226            let results_no_collect = find_dead_code_full(
1227                &graph,
1228                &config,
1229                &[],
1230                None,
1231                &[],
1232                &[],
1233                false, // collect_usages = false
1234            );
1235            assert!(
1236                results_no_collect.export_usages.is_empty(),
1237                "export_usages should be empty when collect_usages is false"
1238            );
1239
1240            let results_with_collect = find_dead_code_full(
1241                &graph,
1242                &config,
1243                &[],
1244                None,
1245                &[],
1246                &[],
1247                true, // collect_usages = true
1248            );
1249            assert!(
1250                !results_with_collect.export_usages.is_empty(),
1251                "export_usages should be populated when collect_usages is true"
1252            );
1253            assert_eq!(
1254                results_with_collect.export_usages[0].export_name,
1255                "myExport"
1256            );
1257        }
1258
1259        #[test]
1260        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1261            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1262            use crate::graph::ModuleGraph;
1263            use crate::resolve::ResolvedModule;
1264            use rustc_hash::FxHashSet;
1265
1266            let files = vec![DiscoveredFile {
1267                id: FileId(0),
1268                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1269                size_bytes: 100,
1270            }];
1271            let entry_points = vec![EntryPoint {
1272                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1273                source: EntryPointSource::ManualEntry,
1274            }];
1275            let resolved = vec![ResolvedModule {
1276                file_id: FileId(0),
1277                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1278                exports: vec![],
1279                re_exports: vec![],
1280                resolved_imports: vec![],
1281                resolved_dynamic_imports: vec![],
1282                resolved_dynamic_patterns: vec![],
1283                member_accesses: vec![],
1284                whole_object_uses: vec![],
1285                has_cjs_exports: false,
1286                has_angular_component_template_url: false,
1287                unused_import_bindings: FxHashSet::default(),
1288                type_referenced_import_bindings: vec![],
1289                value_referenced_import_bindings: vec![],
1290                namespace_object_aliases: vec![],
1291            }];
1292            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1293            let config = make_config_with_rules(RulesConfig::default());
1294
1295            let results = find_dead_code(&graph, &config);
1296            assert!(results.unused_exports.is_empty());
1297        }
1298
1299        #[test]
1300        fn suppressions_built_from_modules() {
1301            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1302            use crate::extract::ModuleInfo;
1303            use crate::graph::ModuleGraph;
1304            use crate::resolve::ResolvedModule;
1305            use crate::suppress::{IssueKind, Suppression};
1306            use rustc_hash::FxHashSet;
1307
1308            let files = vec![
1309                DiscoveredFile {
1310                    id: FileId(0),
1311                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1312                    size_bytes: 100,
1313                },
1314                DiscoveredFile {
1315                    id: FileId(1),
1316                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1317                    size_bytes: 100,
1318                },
1319            ];
1320            let entry_points = vec![EntryPoint {
1321                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1322                source: EntryPointSource::ManualEntry,
1323            }];
1324            let resolved = files
1325                .iter()
1326                .map(|f| ResolvedModule {
1327                    file_id: f.id,
1328                    path: f.path.clone(),
1329                    exports: vec![],
1330                    re_exports: vec![],
1331                    resolved_imports: vec![],
1332                    resolved_dynamic_imports: vec![],
1333                    resolved_dynamic_patterns: vec![],
1334                    member_accesses: vec![],
1335                    whole_object_uses: vec![],
1336                    has_cjs_exports: false,
1337                    has_angular_component_template_url: false,
1338                    unused_import_bindings: FxHashSet::default(),
1339                    type_referenced_import_bindings: vec![],
1340                    value_referenced_import_bindings: vec![],
1341                    namespace_object_aliases: vec![],
1342                })
1343                .collect::<Vec<_>>();
1344            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1345
1346            let modules = vec![ModuleInfo {
1347                file_id: FileId(1),
1348                exports: vec![],
1349                imports: vec![],
1350                re_exports: vec![],
1351                dynamic_imports: vec![],
1352                dynamic_import_patterns: vec![],
1353                require_calls: vec![],
1354                member_accesses: vec![],
1355                whole_object_uses: vec![],
1356                has_cjs_exports: false,
1357                has_angular_component_template_url: false,
1358                content_hash: 0,
1359                suppressions: vec![Suppression {
1360                    line: 0,
1361                    comment_line: 1,
1362                    kind: Some(IssueKind::UnusedFile),
1363                }],
1364                unknown_suppression_kinds: vec![],
1365                unused_import_bindings: vec![],
1366                type_referenced_import_bindings: vec![],
1367                value_referenced_import_bindings: vec![],
1368                line_offsets: vec![],
1369                complexity: vec![],
1370                flag_uses: vec![],
1371                class_heritage: vec![],
1372                local_type_declarations: Vec::new(),
1373                public_signature_type_references: Vec::new(),
1374                namespace_object_aliases: Vec::new(),
1375                iconify_prefixes: Vec::new(),
1376                auto_import_candidates: Vec::new(),
1377                directives: Vec::new(),
1378                security_sinks: Vec::new(),
1379                security_sinks_skipped: 0,
1380                tainted_bindings: Vec::new(),
1381                sanitized_sink_args: Vec::new(),
1382            }];
1383
1384            let rules = RulesConfig {
1385                unused_files: Severity::Error,
1386                ..RulesConfig::default()
1387            };
1388            let config = make_config_with_rules(rules);
1389
1390            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1391
1392            assert!(
1393                !results.unused_files.iter().any(|f| f
1394                    .file
1395                    .path
1396                    .to_string_lossy()
1397                    .contains("utils.ts")),
1398                "suppressed file should not appear in unused_files"
1399            );
1400        }
1401    }
1402}