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