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                || run_unused_file_detector(graph, config, &suppressions),
561                || {
562                    run_export_detectors(
563                        graph,
564                        modules,
565                        config,
566                        plugin_result,
567                        &suppressions,
568                        &line_offsets_by_file,
569                    )
570                },
571            )
572        },
573        || {
574            rayon::join(
575                || {
576                    rayon::join(
577                        || {
578                            run_member_detectors(
579                                graph,
580                                resolved_modules,
581                                modules,
582                                config,
583                                &suppressions,
584                                &line_offsets_by_file,
585                                &user_class_members,
586                                &public_api_entry_points,
587                            )
588                        },
589                        || {
590                            run_dependency_detectors(
591                                graph,
592                                pkg.as_ref(),
593                                config,
594                                plugin_result,
595                                workspaces,
596                                resolved_modules,
597                                &line_offsets_by_file,
598                            )
599                        },
600                    )
601                },
602                || {
603                    rayon::join(
604                        || {
605                            rayon::join(
606                                || {
607                                    run_unresolved_import_detector(
608                                        resolved_modules,
609                                        config,
610                                        &suppressions,
611                                        &virtual_prefixes,
612                                        &generated_patterns,
613                                        &generated_type_prefixes,
614                                        &line_offsets_by_file,
615                                    )
616                                },
617                                || {
618                                    if config.rules.duplicate_exports != Severity::Off {
619                                        let duplicate_exports =
620                                            if let Some(plugin_result) = plugin_result {
621                                                unused_exports::find_duplicate_exports_with_plugins(
622                                                    graph,
623                                                    config,
624                                                    &suppressions,
625                                                    &line_offsets_by_file,
626                                                    Some(plugin_result),
627                                                    resolved_modules,
628                                                )
629                                            } else {
630                                                unused_exports::find_duplicate_exports(
631                                                    graph,
632                                                    config,
633                                                    &suppressions,
634                                                    &line_offsets_by_file,
635                                                    resolved_modules,
636                                                )
637                                            };
638                                        duplicate_exports
639                                            .into_iter()
640                                            .map(DuplicateExportFinding::with_actions)
641                                            .collect::<Vec<_>>()
642                                    } else {
643                                        Vec::new()
644                                    }
645                                },
646                            )
647                        },
648                        || {
649                            rayon::join(
650                                || {
651                                    if config.rules.boundary_violation != Severity::Off
652                                        && !config.boundaries.is_empty()
653                                    {
654                                        boundary::find_boundary_violations(
655                                            graph,
656                                            config,
657                                            &suppressions,
658                                            &line_offsets_by_file,
659                                        )
660                                        .into_iter()
661                                        .map(BoundaryViolationFinding::with_actions)
662                                        .collect::<Vec<_>>()
663                                    } else {
664                                        Vec::new()
665                                    }
666                                },
667                                || {
668                                    rayon::join(
669                                        || {
670                                            run_circular_dep_detector(
671                                                graph,
672                                                config,
673                                                &line_offsets_by_file,
674                                                &suppressions,
675                                                workspaces,
676                                            )
677                                        },
678                                        || {
679                                            rayon::join(
680                                                || {
681                                                    run_re_export_cycle_detector(
682                                                        graph,
683                                                        config,
684                                                        &suppressions,
685                                                    )
686                                                },
687                                                || {
688                                                    run_export_usages_collector(
689                                                        graph,
690                                                        &line_offsets_by_file,
691                                                        collect_usages,
692                                                    )
693                                                },
694                                            )
695                                        },
696                                    )
697                                },
698                            )
699                        },
700                    )
701                },
702            )
703        },
704    );
705
706    let mut results = AnalysisResults {
707        unused_files,
708        unused_exports: export_results.unused_exports,
709        unused_types: export_results.unused_types,
710        private_type_leaks: export_results.private_type_leaks,
711        stale_suppressions: export_results.stale_suppressions,
712        unused_enum_members: member_results.unused_enum_members,
713        unused_class_members: member_results.unused_class_members,
714        unused_dependencies: dependency_results.unused_dependencies,
715        unused_dev_dependencies: dependency_results.unused_dev_dependencies,
716        unused_optional_dependencies: dependency_results.unused_optional_dependencies,
717        unlisted_dependencies: dependency_results.unlisted_dependencies,
718        type_only_dependencies: dependency_results.type_only_dependencies,
719        test_only_dependencies: dependency_results.test_only_dependencies,
720        unresolved_imports,
721        duplicate_exports,
722        boundary_violations,
723        circular_dependencies,
724        re_export_cycles,
725        export_usages,
726        ..AnalysisResults::default()
727    };
728
729    let public_roots = public_workspace_roots(&config.public_packages, workspaces);
730    if !public_roots.is_empty() {
731        results.unused_exports.retain(|e| {
732            !public_roots
733                .iter()
734                .any(|root| e.export.path.starts_with(root))
735        });
736        results.unused_types.retain(|e| {
737            !public_roots
738                .iter()
739                .any(|root| e.export.path.starts_with(root))
740        });
741        results.unused_enum_members.retain(|e| {
742            !public_roots
743                .iter()
744                .any(|root| e.member.path.starts_with(root))
745        });
746        results.unused_class_members.retain(|e| {
747            !public_roots
748                .iter()
749                .any(|root| e.member.path.starts_with(root))
750        });
751    }
752
753    let declared_deps = collect_declared_dependency_names(config, pkg.as_ref(), workspaces);
754
755    if config.rules.security_client_server_leak != Severity::Off {
756        let (security_findings, stats) =
757            security::find_security_findings(graph, modules, &suppressions, &line_offsets_by_file);
758        results.security_findings = security_findings;
759        results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
760    }
761
762    if config.rules.security_sink != Severity::Off {
763        let categories = config.security.categories.as_ref();
764        let filter = security::CategoryFilter::new(
765            categories.and_then(|c| c.include.clone()),
766            categories.and_then(|c| c.exclude.clone()),
767        );
768        // Framework-scoped catalogue rows (#861) gate on the active framework via
769        // the project's declared dependency set: the same dependency universe the
770        // plugin system activates on (root package.json + every workspace
771        // package.json). Built once here and passed to the detector.
772        let (sink_findings, sink_stats) = security::find_tainted_sinks(
773            graph,
774            modules,
775            &suppressions,
776            &line_offsets_by_file,
777            &filter,
778            &declared_deps,
779            &config.root,
780        );
781        results.security_findings.extend(sink_findings);
782        results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
783        results
784            .security_findings
785            .extend(security::find_hardcoded_secret_candidates(
786                graph,
787                modules,
788                &suppressions,
789                &line_offsets_by_file,
790                &filter,
791                &config.root,
792            ));
793    }
794
795    // Reachability-weighted ranking (issue #860): order security candidates so
796    // those reachable from a runtime/application entry point with a wider
797    // blast radius surface above isolated helpers/scripts. Reuses the existing
798    // graph reachability + reverse-dep fan-in; pairs optionally with boundary
799    // crossings already computed this run. Issue #884 adds a dead-code cross-link
800    // before ranking so removable sink candidates sort below active-code peers.
801    if !results.security_findings.is_empty() {
802        security::annotate_dead_code_cross_links(
803            graph,
804            modules,
805            &line_offsets_by_file,
806            &results.unused_files,
807            &results.unused_exports,
808            &mut results.security_findings,
809        );
810        // Map each boundary-violation file (importer or imported side) to the
811        // (from_zone, to_zone) it crosses, so security ranking can flag
812        // `crosses_boundary` AND fill the candidate's architecture-zone slot
813        // (issue #900). `boundary_violations` is not yet path-sorted here (the
814        // output sort runs later), so for a file participating in more than one
815        // zone crossing pick the lexicographically smallest pair, which is
816        // independent of insertion order and therefore stable across runs.
817        let mut boundary_crossings: rustc_hash::FxHashMap<std::path::PathBuf, (String, String)> =
818            rustc_hash::FxHashMap::default();
819        for violation in &results.boundary_violations {
820            let zones = (
821                violation.violation.from_zone.clone(),
822                violation.violation.to_zone.clone(),
823            );
824            for path in [
825                violation.violation.from_path.clone(),
826                violation.violation.to_path.clone(),
827            ] {
828                boundary_crossings
829                    .entry(path)
830                    .and_modify(|existing| {
831                        if zones < *existing {
832                            *existing = zones.clone();
833                        }
834                    })
835                    .or_insert_with(|| zones.clone());
836            }
837        }
838        security::rank_security_findings(
839            graph,
840            modules,
841            &line_offsets_by_file,
842            &declared_deps,
843            &boundary_crossings,
844            &mut results.security_findings,
845        );
846    }
847
848    if config.rules.stale_suppressions != Severity::Off {
849        results
850            .stale_suppressions
851            .extend(suppressions.find_stale(graph, config));
852    }
853    results.suppression_count = suppressions.used_count();
854    results.active_suppressions = suppressions.all_suppressions(graph);
855
856    let need_unused_catalogs = config.rules.unused_catalog_entries != Severity::Off;
857    let need_empty_catalog_groups = config.rules.empty_catalog_groups != Severity::Off;
858    let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
859    if (need_unused_catalogs || need_empty_catalog_groups || need_unresolved_refs)
860        && let Some(state) = gather_pnpm_catalog_state(config, workspaces)
861    {
862        if need_unused_catalogs {
863            results.unused_catalog_entries = find_unused_catalog_entries(&state)
864                .into_iter()
865                .map(UnusedCatalogEntryFinding::with_actions)
866                .collect();
867        }
868        if need_empty_catalog_groups {
869            results.empty_catalog_groups = find_empty_catalog_groups(&state)
870                .into_iter()
871                .map(EmptyCatalogGroupFinding::with_actions)
872                .collect();
873        }
874        if need_unresolved_refs {
875            results.unresolved_catalog_references = find_unresolved_catalog_references(
876                &state,
877                &config.compiled_ignore_catalog_references,
878                &config.root,
879            )
880            .into_iter()
881            .map(UnresolvedCatalogReferenceFinding::with_actions)
882            .collect();
883        }
884    }
885
886    let need_unused_overrides = config.rules.unused_dependency_overrides != Severity::Off;
887    let need_misconfigured_overrides =
888        config.rules.misconfigured_dependency_overrides != Severity::Off;
889    if (need_unused_overrides || need_misconfigured_overrides)
890        && let Some(state) = gather_pnpm_override_state(config, workspaces)
891    {
892        if need_unused_overrides {
893            results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
894                .into_iter()
895                .map(UnusedDependencyOverrideFinding::with_actions)
896                .collect();
897        }
898        if need_misconfigured_overrides {
899            results.misconfigured_dependency_overrides =
900                find_misconfigured_dependency_overrides(&state, config)
901                    .into_iter()
902                    .map(MisconfiguredDependencyOverrideFinding::with_actions)
903                    .collect();
904        }
905    }
906
907    results.sort();
908
909    results
910}
911
912#[expect(
913    deprecated,
914    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
915)]
916fn run_unused_file_detector(
917    graph: &ModuleGraph,
918    config: &ResolvedConfig,
919    suppressions: &crate::suppress::SuppressionContext<'_>,
920) -> Vec<UnusedFileFinding> {
921    if config.rules.unused_files == Severity::Off {
922        return Vec::new();
923    }
924    find_unused_files(graph, suppressions)
925        .into_iter()
926        .map(UnusedFileFinding::with_actions)
927        .collect()
928}
929
930#[expect(
931    deprecated,
932    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
933)]
934fn run_export_detectors(
935    graph: &ModuleGraph,
936    modules: &[ModuleInfo],
937    config: &ResolvedConfig,
938    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
939    suppressions: &crate::suppress::SuppressionContext<'_>,
940    line_offsets_by_file: &LineOffsetsMap<'_>,
941) -> AnalysisResults {
942    let mut results = AnalysisResults::default();
943    if config.rules.unused_exports == Severity::Off
944        && config.rules.unused_types == Severity::Off
945        && config.rules.private_type_leaks == Severity::Off
946    {
947        return results;
948    }
949
950    let (exports, types, stale_expected) = find_unused_exports(
951        graph,
952        modules,
953        config,
954        plugin_result,
955        suppressions,
956        line_offsets_by_file,
957    );
958    if config.rules.unused_exports != Severity::Off {
959        results.unused_exports = exports
960            .into_iter()
961            .map(UnusedExportFinding::with_actions)
962            .collect();
963    }
964    if config.rules.unused_types != Severity::Off {
965        let mut typed = types;
966        suppress_signature_backing_types(&mut typed, graph, modules);
967        results.unused_types = typed
968            .into_iter()
969            .map(UnusedTypeFinding::with_actions)
970            .collect();
971    }
972    if config.rules.private_type_leaks != Severity::Off {
973        results.private_type_leaks =
974            find_private_type_leaks(graph, modules, config, suppressions, line_offsets_by_file)
975                .into_iter()
976                .map(PrivateTypeLeakFinding::with_actions)
977                .collect();
978    }
979    if config.rules.stale_suppressions != Severity::Off {
980        results.stale_suppressions.extend(stale_expected);
981    }
982    results
983}
984
985#[expect(
986    clippy::too_many_arguments,
987    reason = "member detection needs graph context plus public API and allowlist filters"
988)]
989fn run_member_detectors(
990    graph: &ModuleGraph,
991    resolved_modules: &[ResolvedModule],
992    modules: &[ModuleInfo],
993    config: &ResolvedConfig,
994    suppressions: &crate::suppress::SuppressionContext<'_>,
995    line_offsets_by_file: &LineOffsetsMap<'_>,
996    user_class_members: &[fallow_config::UsedClassMemberRule],
997    public_api_entry_points: &FxHashSet<FileId>,
998) -> AnalysisResults {
999    let mut results = AnalysisResults::default();
1000    if config.rules.unused_enum_members == Severity::Off
1001        && config.rules.unused_class_members == Severity::Off
1002    {
1003        return results;
1004    }
1005
1006    let (enum_members, class_members) = find_unused_members_with_public_api_entry_points(
1007        graph,
1008        resolved_modules,
1009        modules,
1010        suppressions,
1011        line_offsets_by_file,
1012        user_class_members,
1013        &config.ignore_decorators,
1014        public_api_entry_points,
1015    );
1016    if config.rules.unused_enum_members != Severity::Off {
1017        results.unused_enum_members = enum_members
1018            .into_iter()
1019            .map(UnusedEnumMemberFinding::with_actions)
1020            .collect();
1021    }
1022    if config.rules.unused_class_members != Severity::Off {
1023        results.unused_class_members = class_members
1024            .into_iter()
1025            .map(UnusedClassMemberFinding::with_actions)
1026            .collect();
1027    }
1028    results
1029}
1030
1031#[expect(
1032    deprecated,
1033    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1034)]
1035fn run_dependency_detectors(
1036    graph: &ModuleGraph,
1037    pkg: Option<&PackageJson>,
1038    config: &ResolvedConfig,
1039    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
1040    workspaces: &[fallow_config::WorkspaceInfo],
1041    resolved_modules: &[ResolvedModule],
1042    line_offsets_by_file: &LineOffsetsMap<'_>,
1043) -> AnalysisResults {
1044    let mut results = AnalysisResults::default();
1045    let Some(pkg) = pkg else {
1046        return results;
1047    };
1048
1049    if config.rules.unused_dependencies != Severity::Off
1050        || config.rules.unused_dev_dependencies != Severity::Off
1051        || config.rules.unused_optional_dependencies != Severity::Off
1052    {
1053        let (deps, dev_deps, optional_deps) =
1054            find_unused_dependencies(graph, pkg, config, plugin_result, workspaces);
1055        if config.rules.unused_dependencies != Severity::Off {
1056            results.unused_dependencies = deps
1057                .into_iter()
1058                .map(UnusedDependencyFinding::with_actions)
1059                .collect();
1060        }
1061        if config.rules.unused_dev_dependencies != Severity::Off {
1062            results.unused_dev_dependencies = dev_deps
1063                .into_iter()
1064                .map(UnusedDevDependencyFinding::with_actions)
1065                .collect();
1066        }
1067        if config.rules.unused_optional_dependencies != Severity::Off {
1068            results.unused_optional_dependencies = optional_deps
1069                .into_iter()
1070                .map(UnusedOptionalDependencyFinding::with_actions)
1071                .collect();
1072        }
1073    }
1074
1075    if config.rules.unlisted_dependencies != Severity::Off {
1076        results.unlisted_dependencies = find_unlisted_dependencies(
1077            graph,
1078            pkg,
1079            config,
1080            workspaces,
1081            plugin_result,
1082            resolved_modules,
1083            line_offsets_by_file,
1084        )
1085        .into_iter()
1086        .map(UnlistedDependencyFinding::with_actions)
1087        .collect();
1088    }
1089
1090    if config.production {
1091        results.type_only_dependencies =
1092            find_type_only_dependencies(graph, pkg, config, workspaces)
1093                .into_iter()
1094                .map(TypeOnlyDependencyFinding::with_actions)
1095                .collect();
1096    }
1097
1098    if !config.production && config.rules.test_only_dependencies != Severity::Off {
1099        results.test_only_dependencies =
1100            find_test_only_dependencies(graph, pkg, config, workspaces)
1101                .into_iter()
1102                .map(TestOnlyDependencyFinding::with_actions)
1103                .collect();
1104    }
1105    results
1106}
1107
1108fn run_unresolved_import_detector(
1109    resolved_modules: &[ResolvedModule],
1110    config: &ResolvedConfig,
1111    suppressions: &crate::suppress::SuppressionContext<'_>,
1112    virtual_prefixes: &[&str],
1113    generated_patterns: &[&str],
1114    generated_type_prefixes: &[&str],
1115    line_offsets_by_file: &LineOffsetsMap<'_>,
1116) -> Vec<UnresolvedImportFinding> {
1117    if config.rules.unresolved_imports == Severity::Off || resolved_modules.is_empty() {
1118        return Vec::new();
1119    }
1120    find_unresolved_imports(
1121        resolved_modules,
1122        config,
1123        suppressions,
1124        virtual_prefixes,
1125        generated_patterns,
1126        generated_type_prefixes,
1127        line_offsets_by_file,
1128    )
1129    .into_iter()
1130    .map(UnresolvedImportFinding::with_actions)
1131    .collect()
1132}
1133
1134#[cfg(test)]
1135#[expect(
1136    deprecated,
1137    reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
1138)]
1139mod tests {
1140    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
1141
1142    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
1143        let offsets = compute_line_offsets(source);
1144        byte_offset_to_line_col(&offsets, byte_offset)
1145    }
1146
1147    #[test]
1148    fn compute_offsets_empty() {
1149        assert_eq!(compute_line_offsets(""), vec![0]);
1150    }
1151
1152    #[test]
1153    fn compute_offsets_single_line() {
1154        assert_eq!(compute_line_offsets("hello"), vec![0]);
1155    }
1156
1157    #[test]
1158    fn compute_offsets_multiline() {
1159        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
1160    }
1161
1162    #[test]
1163    fn compute_offsets_trailing_newline() {
1164        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
1165    }
1166
1167    #[test]
1168    fn compute_offsets_crlf() {
1169        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
1170    }
1171
1172    #[test]
1173    fn compute_offsets_consecutive_newlines() {
1174        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
1175    }
1176
1177    #[test]
1178    fn byte_offset_empty_source() {
1179        assert_eq!(line_col("", 0), (1, 0));
1180    }
1181
1182    #[test]
1183    fn byte_offset_single_line_start() {
1184        assert_eq!(line_col("hello", 0), (1, 0));
1185    }
1186
1187    #[test]
1188    fn byte_offset_single_line_middle() {
1189        assert_eq!(line_col("hello", 4), (1, 4));
1190    }
1191
1192    #[test]
1193    fn byte_offset_multiline_start_of_line2() {
1194        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
1195    }
1196
1197    #[test]
1198    fn byte_offset_multiline_middle_of_line3() {
1199        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
1200    }
1201
1202    #[test]
1203    fn byte_offset_at_newline_boundary() {
1204        assert_eq!(line_col("line1\nline2", 5), (1, 5));
1205    }
1206
1207    #[test]
1208    fn byte_offset_multibyte_utf8() {
1209        let source = "hi\n\u{1F600}x";
1210        assert_eq!(line_col(source, 3), (2, 0));
1211        assert_eq!(line_col(source, 7), (2, 4));
1212    }
1213
1214    #[test]
1215    fn byte_offset_multibyte_accented_chars() {
1216        let source = "caf\u{00E9}\nbar";
1217        assert_eq!(line_col(source, 6), (2, 0));
1218        assert_eq!(line_col(source, 3), (1, 3));
1219    }
1220
1221    #[test]
1222    fn byte_offset_via_map_fallback() {
1223        use super::*;
1224        let map: LineOffsetsMap<'_> = FxHashMap::default();
1225        assert_eq!(
1226            super::byte_offset_to_line_col(&map, FileId(99), 42),
1227            (1, 42)
1228        );
1229    }
1230
1231    #[test]
1232    fn byte_offset_via_map_lookup() {
1233        use super::*;
1234        let offsets = compute_line_offsets("abc\ndef\nghi");
1235        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1236        map.insert(FileId(0), &offsets);
1237        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1238    }
1239
1240    mod orchestration {
1241        use super::super::*;
1242        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1243        use std::path::PathBuf;
1244
1245        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1246            find_dead_code_full(graph, config, &[], None, &[], &[], false)
1247        }
1248
1249        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1250            FallowConfig {
1251                rules,
1252                ..Default::default()
1253            }
1254            .resolve(
1255                PathBuf::from("/tmp/orchestration-test"),
1256                OutputFormat::Human,
1257                1,
1258                true,
1259                true,
1260                None,
1261            )
1262        }
1263
1264        #[test]
1265        fn find_dead_code_all_rules_off_returns_empty() {
1266            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1267            use crate::graph::ModuleGraph;
1268            use crate::resolve::ResolvedModule;
1269            use rustc_hash::FxHashSet;
1270
1271            let files = vec![DiscoveredFile {
1272                id: FileId(0),
1273                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1274                size_bytes: 100,
1275            }];
1276            let entry_points = vec![EntryPoint {
1277                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1278                source: EntryPointSource::ManualEntry,
1279            }];
1280            let resolved = vec![ResolvedModule {
1281                file_id: FileId(0),
1282                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1283                exports: vec![],
1284                re_exports: vec![],
1285                resolved_imports: vec![],
1286                resolved_dynamic_imports: vec![],
1287                resolved_dynamic_patterns: vec![],
1288                member_accesses: vec![],
1289                whole_object_uses: vec![],
1290                has_cjs_exports: false,
1291                has_angular_component_template_url: false,
1292                unused_import_bindings: FxHashSet::default(),
1293                type_referenced_import_bindings: vec![],
1294                value_referenced_import_bindings: vec![],
1295                namespace_object_aliases: vec![],
1296            }];
1297            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1298
1299            let rules = RulesConfig {
1300                unused_files: Severity::Off,
1301                unused_exports: Severity::Off,
1302                unused_types: Severity::Off,
1303                private_type_leaks: Severity::Off,
1304                unused_dependencies: Severity::Off,
1305                unused_dev_dependencies: Severity::Off,
1306                unused_optional_dependencies: Severity::Off,
1307                unused_enum_members: Severity::Off,
1308                unused_class_members: Severity::Off,
1309                unresolved_imports: Severity::Off,
1310                unlisted_dependencies: Severity::Off,
1311                duplicate_exports: Severity::Off,
1312                type_only_dependencies: Severity::Off,
1313                circular_dependencies: Severity::Off,
1314                re_export_cycle: Severity::Off,
1315                test_only_dependencies: Severity::Off,
1316                boundary_violation: Severity::Off,
1317                coverage_gaps: Severity::Off,
1318                feature_flags: Severity::Off,
1319                stale_suppressions: Severity::Off,
1320                unused_catalog_entries: Severity::Off,
1321                empty_catalog_groups: Severity::Off,
1322                unresolved_catalog_references: Severity::Off,
1323                unused_dependency_overrides: Severity::Off,
1324                misconfigured_dependency_overrides: Severity::Off,
1325                security_client_server_leak: Severity::Off,
1326                security_sink: Severity::Off,
1327            };
1328            let config = make_config_with_rules(rules);
1329            let results = find_dead_code(&graph, &config);
1330
1331            assert!(results.unused_files.is_empty());
1332            assert!(results.unused_exports.is_empty());
1333            assert!(results.unused_types.is_empty());
1334            assert!(results.unused_dependencies.is_empty());
1335            assert!(results.unused_dev_dependencies.is_empty());
1336            assert!(results.unused_optional_dependencies.is_empty());
1337            assert!(results.unused_enum_members.is_empty());
1338            assert!(results.unused_class_members.is_empty());
1339            assert!(results.unresolved_imports.is_empty());
1340            assert!(results.unlisted_dependencies.is_empty());
1341            assert!(results.duplicate_exports.is_empty());
1342            assert!(results.circular_dependencies.is_empty());
1343            assert!(results.export_usages.is_empty());
1344        }
1345
1346        #[test]
1347        fn find_dead_code_full_collect_usages_flag() {
1348            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1349            use crate::extract::{ExportName, VisibilityTag};
1350            use crate::graph::{ExportSymbol, ModuleGraph};
1351            use crate::resolve::ResolvedModule;
1352            use oxc_span::Span;
1353            use rustc_hash::FxHashSet;
1354
1355            let files = vec![DiscoveredFile {
1356                id: FileId(0),
1357                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1358                size_bytes: 100,
1359            }];
1360            let entry_points = vec![EntryPoint {
1361                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1362                source: EntryPointSource::ManualEntry,
1363            }];
1364            let resolved = vec![ResolvedModule {
1365                file_id: FileId(0),
1366                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1367                exports: vec![],
1368                re_exports: vec![],
1369                resolved_imports: vec![],
1370                resolved_dynamic_imports: vec![],
1371                resolved_dynamic_patterns: vec![],
1372                member_accesses: vec![],
1373                whole_object_uses: vec![],
1374                has_cjs_exports: false,
1375                has_angular_component_template_url: false,
1376                unused_import_bindings: FxHashSet::default(),
1377                type_referenced_import_bindings: vec![],
1378                value_referenced_import_bindings: vec![],
1379                namespace_object_aliases: vec![],
1380            }];
1381            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1382            graph.modules[0].exports = vec![ExportSymbol {
1383                name: ExportName::Named("myExport".to_string()),
1384                is_type_only: false,
1385                is_side_effect_used: false,
1386                visibility: VisibilityTag::None,
1387                span: Span::new(10, 30),
1388                references: vec![],
1389                members: vec![],
1390            }];
1391
1392            let rules = RulesConfig::default();
1393            let config = make_config_with_rules(rules);
1394
1395            let results_no_collect = find_dead_code_full(
1396                &graph,
1397                &config,
1398                &[],
1399                None,
1400                &[],
1401                &[],
1402                false, // collect_usages = false
1403            );
1404            assert!(
1405                results_no_collect.export_usages.is_empty(),
1406                "export_usages should be empty when collect_usages is false"
1407            );
1408
1409            let results_with_collect = find_dead_code_full(
1410                &graph,
1411                &config,
1412                &[],
1413                None,
1414                &[],
1415                &[],
1416                true, // collect_usages = true
1417            );
1418            assert!(
1419                !results_with_collect.export_usages.is_empty(),
1420                "export_usages should be populated when collect_usages is true"
1421            );
1422            assert_eq!(
1423                results_with_collect.export_usages[0].export_name,
1424                "myExport"
1425            );
1426        }
1427
1428        #[test]
1429        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1430            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1431            use crate::graph::ModuleGraph;
1432            use crate::resolve::ResolvedModule;
1433            use rustc_hash::FxHashSet;
1434
1435            let files = vec![DiscoveredFile {
1436                id: FileId(0),
1437                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1438                size_bytes: 100,
1439            }];
1440            let entry_points = vec![EntryPoint {
1441                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1442                source: EntryPointSource::ManualEntry,
1443            }];
1444            let resolved = vec![ResolvedModule {
1445                file_id: FileId(0),
1446                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1447                exports: vec![],
1448                re_exports: vec![],
1449                resolved_imports: vec![],
1450                resolved_dynamic_imports: vec![],
1451                resolved_dynamic_patterns: vec![],
1452                member_accesses: vec![],
1453                whole_object_uses: vec![],
1454                has_cjs_exports: false,
1455                has_angular_component_template_url: false,
1456                unused_import_bindings: FxHashSet::default(),
1457                type_referenced_import_bindings: vec![],
1458                value_referenced_import_bindings: vec![],
1459                namespace_object_aliases: vec![],
1460            }];
1461            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1462            let config = make_config_with_rules(RulesConfig::default());
1463
1464            let results = find_dead_code(&graph, &config);
1465            assert!(results.unused_exports.is_empty());
1466        }
1467
1468        #[test]
1469        fn suppressions_built_from_modules() {
1470            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1471            use crate::extract::ModuleInfo;
1472            use crate::graph::ModuleGraph;
1473            use crate::resolve::ResolvedModule;
1474            use crate::suppress::{IssueKind, Suppression};
1475            use rustc_hash::FxHashSet;
1476
1477            let files = vec![
1478                DiscoveredFile {
1479                    id: FileId(0),
1480                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1481                    size_bytes: 100,
1482                },
1483                DiscoveredFile {
1484                    id: FileId(1),
1485                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1486                    size_bytes: 100,
1487                },
1488            ];
1489            let entry_points = vec![EntryPoint {
1490                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1491                source: EntryPointSource::ManualEntry,
1492            }];
1493            let resolved = files
1494                .iter()
1495                .map(|f| ResolvedModule {
1496                    file_id: f.id,
1497                    path: f.path.clone(),
1498                    exports: vec![],
1499                    re_exports: vec![],
1500                    resolved_imports: vec![],
1501                    resolved_dynamic_imports: vec![],
1502                    resolved_dynamic_patterns: vec![],
1503                    member_accesses: vec![],
1504                    whole_object_uses: vec![],
1505                    has_cjs_exports: false,
1506                    has_angular_component_template_url: false,
1507                    unused_import_bindings: FxHashSet::default(),
1508                    type_referenced_import_bindings: vec![],
1509                    value_referenced_import_bindings: vec![],
1510                    namespace_object_aliases: vec![],
1511                })
1512                .collect::<Vec<_>>();
1513            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1514
1515            let modules = vec![ModuleInfo {
1516                file_id: FileId(1),
1517                exports: vec![],
1518                imports: vec![],
1519                re_exports: vec![],
1520                dynamic_imports: vec![],
1521                dynamic_import_patterns: vec![],
1522                require_calls: vec![],
1523                package_path_references: vec![],
1524                member_accesses: vec![],
1525                whole_object_uses: vec![],
1526                has_cjs_exports: false,
1527                has_angular_component_template_url: false,
1528                content_hash: 0,
1529                suppressions: vec![Suppression {
1530                    line: 0,
1531                    comment_line: 1,
1532                    kind: Some(IssueKind::UnusedFile),
1533                }],
1534                unknown_suppression_kinds: vec![],
1535                unused_import_bindings: vec![],
1536                type_referenced_import_bindings: vec![],
1537                value_referenced_import_bindings: vec![],
1538                line_offsets: vec![],
1539                complexity: vec![],
1540                flag_uses: vec![],
1541                class_heritage: vec![],
1542                injection_tokens: vec![],
1543                local_type_declarations: Vec::new(),
1544                public_signature_type_references: Vec::new(),
1545                namespace_object_aliases: Vec::new(),
1546                iconify_prefixes: Vec::new(),
1547                iconify_icon_names: Vec::new(),
1548                auto_import_candidates: Vec::new(),
1549                directives: Vec::new(),
1550                security_sinks: Vec::new(),
1551                security_sinks_skipped: 0,
1552                tainted_bindings: Vec::new(),
1553                sanitized_sink_args: Vec::new(),
1554                security_control_sites: Vec::new(),
1555            }];
1556
1557            let rules = RulesConfig {
1558                unused_files: Severity::Error,
1559                ..RulesConfig::default()
1560            };
1561            let config = make_config_with_rules(rules);
1562
1563            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1564
1565            assert!(
1566                !results.unused_files.iter().any(|f| f
1567                    .file
1568                    .path
1569                    .to_string_lossy()
1570                    .contains("utils.ts")),
1571                "suppressed file should not appear in unused_files"
1572            );
1573        }
1574    }
1575}