Skip to main content

fallow_core/analyze/
mod.rs

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