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;
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#[expect(
584    deprecated,
585    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
586)]
587#[deprecated(
588    since = "2.76.0",
589    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."
590)]
591#[expect(
592    clippy::too_many_lines,
593    reason = "orchestration function calling all detectors; each call is one-line and the sequence is easier to follow in one place"
594)]
595pub fn find_dead_code_full(
596    graph: &ModuleGraph,
597    config: &ResolvedConfig,
598    resolved_modules: &[ResolvedModule],
599    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
600    workspaces: &[fallow_config::WorkspaceInfo],
601    modules: &[ModuleInfo],
602    collect_usages: bool,
603) -> AnalysisResults {
604    let _span = tracing::info_span!("find_dead_code").entered();
605
606    let suppressions = crate::suppress::SuppressionContext::new(modules);
607
608    let line_offsets_by_file: LineOffsetsMap<'_> = modules
609        .iter()
610        .filter(|m| !m.line_offsets.is_empty())
611        .map(|m| (m.file_id, m.line_offsets.as_slice()))
612        .collect();
613
614    let pkg_path = config.root.join("package.json");
615    let pkg = PackageJson::load(&pkg_path).ok();
616    let public_api_entry_points =
617        public_api_package_entry_points(graph, config, pkg.as_ref(), workspaces);
618
619    let iconify_referenced =
620        iconify::collect_iconify_referenced_deps(modules, pkg.as_ref(), workspaces);
621    let augmented_plugin_result;
622    let plugin_result = if iconify_referenced.is_empty() {
623        plugin_result
624    } else {
625        let mut owned = plugin_result.cloned().unwrap_or_default();
626        owned.referenced_dependencies.extend(iconify_referenced);
627        augmented_plugin_result = owned;
628        Some(&augmented_plugin_result)
629    };
630
631    let mut user_class_members = config.used_class_members.clone();
632    if let Some(plugin_result) = plugin_result {
633        user_class_members.extend(plugin_result.used_class_members.iter().cloned());
634    }
635
636    let virtual_prefixes: Vec<&str> = plugin_result
637        .map(|pr| {
638            pr.virtual_module_prefixes
639                .iter()
640                .map(String::as_str)
641                .collect()
642        })
643        .unwrap_or_default();
644    let generated_patterns: Vec<&str> = plugin_result
645        .map(|pr| {
646            pr.generated_import_patterns
647                .iter()
648                .map(String::as_str)
649                .collect()
650        })
651        .unwrap_or_default();
652    let generated_type_prefixes: Vec<&str> = plugin_result
653        .map(|pr| {
654            pr.generated_type_import_prefixes
655                .iter()
656                .map(String::as_str)
657                .collect()
658        })
659        .unwrap_or_default();
660
661    let (
662        (unused_files, export_results),
663        (
664            (member_results, dependency_results),
665            (
666                (unresolved_imports, duplicate_exports),
667                (
668                    (
669                        boundary_violations,
670                        (
671                            boundary_coverage_violations,
672                            (boundary_call_violations, policy_violations),
673                        ),
674                    ),
675                    (circular_dependencies, (re_export_cycles, export_usages)),
676                ),
677            ),
678        ),
679    ) = rayon::join(
680        || {
681            rayon::join(
682                || run_unused_file_detector(graph, config, &suppressions),
683                || {
684                    run_export_detectors(
685                        graph,
686                        modules,
687                        config,
688                        plugin_result,
689                        &suppressions,
690                        &line_offsets_by_file,
691                    )
692                },
693            )
694        },
695        || {
696            rayon::join(
697                || {
698                    rayon::join(
699                        || {
700                            run_member_detectors(
701                                graph,
702                                resolved_modules,
703                                modules,
704                                config,
705                                &suppressions,
706                                &line_offsets_by_file,
707                                &user_class_members,
708                                &public_api_entry_points,
709                            )
710                        },
711                        || {
712                            run_dependency_detectors(
713                                graph,
714                                pkg.as_ref(),
715                                config,
716                                plugin_result,
717                                workspaces,
718                                resolved_modules,
719                                &line_offsets_by_file,
720                            )
721                        },
722                    )
723                },
724                || {
725                    rayon::join(
726                        || {
727                            rayon::join(
728                                || {
729                                    run_unresolved_import_detector(
730                                        resolved_modules,
731                                        config,
732                                        &suppressions,
733                                        &virtual_prefixes,
734                                        &generated_patterns,
735                                        &generated_type_prefixes,
736                                        &line_offsets_by_file,
737                                    )
738                                },
739                                || {
740                                    if config.rules.duplicate_exports != Severity::Off {
741                                        let duplicate_exports =
742                                            if let Some(plugin_result) = plugin_result {
743                                                unused_exports::find_duplicate_exports_with_plugins(
744                                                    graph,
745                                                    config,
746                                                    &suppressions,
747                                                    &line_offsets_by_file,
748                                                    Some(plugin_result),
749                                                    resolved_modules,
750                                                )
751                                            } else {
752                                                unused_exports::find_duplicate_exports(
753                                                    graph,
754                                                    config,
755                                                    &suppressions,
756                                                    &line_offsets_by_file,
757                                                    resolved_modules,
758                                                )
759                                            };
760                                        duplicate_exports
761                                            .into_iter()
762                                            .map(DuplicateExportFinding::with_actions)
763                                            .collect::<Vec<_>>()
764                                    } else {
765                                        Vec::new()
766                                    }
767                                },
768                            )
769                        },
770                        || {
771                            rayon::join(
772                                || {
773                                    rayon::join(
774                                        || {
775                                            if config.rules.boundary_violation != Severity::Off
776                                                && !config.boundaries.is_empty()
777                                            {
778                                                boundary::find_boundary_violations(
779                                                    graph,
780                                                    config,
781                                                    &suppressions,
782                                                    &line_offsets_by_file,
783                                                )
784                                                .into_iter()
785                                                .map(BoundaryViolationFinding::with_actions)
786                                                .collect::<Vec<_>>()
787                                            } else {
788                                                Vec::new()
789                                            }
790                                        },
791                                        || {
792                                            run_boundary_aux_detectors(
793                                                graph,
794                                                modules,
795                                                config,
796                                                &suppressions,
797                                                &line_offsets_by_file,
798                                            )
799                                        },
800                                    )
801                                },
802                                || {
803                                    rayon::join(
804                                        || {
805                                            run_circular_dep_detector(
806                                                graph,
807                                                config,
808                                                &line_offsets_by_file,
809                                                &suppressions,
810                                                workspaces,
811                                            )
812                                        },
813                                        || {
814                                            rayon::join(
815                                                || {
816                                                    run_re_export_cycle_detector(
817                                                        graph,
818                                                        config,
819                                                        &suppressions,
820                                                    )
821                                                },
822                                                || {
823                                                    run_export_usages_collector(
824                                                        graph,
825                                                        &line_offsets_by_file,
826                                                        collect_usages,
827                                                    )
828                                                },
829                                            )
830                                        },
831                                    )
832                                },
833                            )
834                        },
835                    )
836                },
837            )
838        },
839    );
840
841    let mut results = AnalysisResults {
842        unused_files,
843        unused_exports: export_results.unused_exports,
844        unused_types: export_results.unused_types,
845        private_type_leaks: export_results.private_type_leaks,
846        stale_suppressions: export_results.stale_suppressions,
847        unused_enum_members: member_results.unused_enum_members,
848        unused_class_members: member_results.unused_class_members,
849        unused_dependencies: dependency_results.unused_dependencies,
850        unused_dev_dependencies: dependency_results.unused_dev_dependencies,
851        unused_optional_dependencies: dependency_results.unused_optional_dependencies,
852        unlisted_dependencies: dependency_results.unlisted_dependencies,
853        type_only_dependencies: dependency_results.type_only_dependencies,
854        test_only_dependencies: dependency_results.test_only_dependencies,
855        unresolved_imports,
856        duplicate_exports,
857        boundary_violations,
858        boundary_coverage_violations,
859        boundary_call_violations,
860        policy_violations,
861        circular_dependencies,
862        re_export_cycles,
863        export_usages,
864        ..AnalysisResults::default()
865    };
866
867    filter_public_workspace_results(config, workspaces, &mut results);
868
869    let declared_deps = collect_declared_dependency_names(config, pkg.as_ref(), workspaces);
870    let request_receivers = config
871        .security
872        .request_receivers
873        .iter()
874        .cloned()
875        .collect::<FxHashSet<_>>();
876
877    populate_security_findings(
878        &SecurityDetectionContext {
879            graph,
880            modules,
881            config,
882            suppressions: &suppressions,
883            line_offsets_by_file: &line_offsets_by_file,
884            declared_deps: &declared_deps,
885            request_receivers: &request_receivers,
886        },
887        &mut results,
888    );
889
890    if config.rules.stale_suppressions != Severity::Off {
891        results
892            .stale_suppressions
893            .extend(suppressions.find_stale(graph, config));
894    }
895    results.suppression_count = suppressions.used_count();
896    results.active_suppressions = suppressions.all_suppressions(graph);
897
898    populate_pnpm_catalog_findings(config, workspaces, &mut results);
899    populate_pnpm_override_findings(config, workspaces, &mut results);
900
901    results.sort();
902
903    results
904}
905
906fn filter_public_workspace_results(
907    config: &ResolvedConfig,
908    workspaces: &[fallow_config::WorkspaceInfo],
909    results: &mut AnalysisResults,
910) {
911    let public_roots = public_workspace_roots(&config.public_packages, workspaces);
912    if public_roots.is_empty() {
913        return;
914    }
915    results.unused_exports.retain(|e| {
916        !public_roots
917            .iter()
918            .any(|root| e.export.path.starts_with(root))
919    });
920    results.unused_types.retain(|e| {
921        !public_roots
922            .iter()
923            .any(|root| e.export.path.starts_with(root))
924    });
925    results.unused_enum_members.retain(|e| {
926        !public_roots
927            .iter()
928            .any(|root| e.member.path.starts_with(root))
929    });
930    results.unused_class_members.retain(|e| {
931        !public_roots
932            .iter()
933            .any(|root| e.member.path.starts_with(root))
934    });
935}
936
937#[expect(
938    deprecated,
939    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
940)]
941fn populate_pnpm_catalog_findings(
942    config: &ResolvedConfig,
943    workspaces: &[fallow_config::WorkspaceInfo],
944    results: &mut AnalysisResults,
945) {
946    let need_unused = config.rules.unused_catalog_entries != Severity::Off;
947    let need_empty_groups = config.rules.empty_catalog_groups != Severity::Off;
948    let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
949    let Some(state) = ((need_unused || need_empty_groups || need_unresolved_refs)
950        .then(|| gather_pnpm_catalog_state(config, workspaces)))
951    .flatten() else {
952        return;
953    };
954
955    if need_unused {
956        results.unused_catalog_entries = find_unused_catalog_entries(&state)
957            .into_iter()
958            .map(UnusedCatalogEntryFinding::with_actions)
959            .collect();
960    }
961    if need_empty_groups {
962        results.empty_catalog_groups = find_empty_catalog_groups(&state)
963            .into_iter()
964            .map(EmptyCatalogGroupFinding::with_actions)
965            .collect();
966    }
967    if need_unresolved_refs {
968        results.unresolved_catalog_references = find_unresolved_catalog_references(
969            &state,
970            &config.compiled_ignore_catalog_references,
971            &config.root,
972        )
973        .into_iter()
974        .map(UnresolvedCatalogReferenceFinding::with_actions)
975        .collect();
976    }
977}
978
979#[expect(
980    deprecated,
981    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
982)]
983fn populate_pnpm_override_findings(
984    config: &ResolvedConfig,
985    workspaces: &[fallow_config::WorkspaceInfo],
986    results: &mut AnalysisResults,
987) {
988    let need_unused = config.rules.unused_dependency_overrides != Severity::Off;
989    let need_misconfigured = config.rules.misconfigured_dependency_overrides != Severity::Off;
990    let Some(state) = ((need_unused || need_misconfigured)
991        .then(|| gather_pnpm_override_state(config, workspaces)))
992    .flatten() else {
993        return;
994    };
995
996    if need_unused {
997        results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
998            .into_iter()
999            .map(UnusedDependencyOverrideFinding::with_actions)
1000            .collect();
1001    }
1002    if need_misconfigured {
1003        results.misconfigured_dependency_overrides =
1004            find_misconfigured_dependency_overrides(&state, config)
1005                .into_iter()
1006                .map(MisconfiguredDependencyOverrideFinding::with_actions)
1007                .collect();
1008    }
1009}
1010
1011fn populate_security_findings(
1012    ctx: &SecurityDetectionContext<'_, '_>,
1013    results: &mut AnalysisResults,
1014) {
1015    if ctx.config.rules.security_client_server_leak != Severity::Off {
1016        let (security_findings, stats) = security::find_security_findings(
1017            ctx.graph,
1018            ctx.modules,
1019            ctx.suppressions,
1020            ctx.line_offsets_by_file,
1021        );
1022        results.security_findings = security_findings;
1023        results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
1024    }
1025
1026    if ctx.config.rules.security_sink != Severity::Off {
1027        populate_tainted_sink_findings(ctx, results);
1028    }
1029
1030    if !results.security_findings.is_empty() {
1031        annotate_security_findings(ctx, results);
1032    }
1033}
1034
1035fn populate_tainted_sink_findings(
1036    ctx: &SecurityDetectionContext<'_, '_>,
1037    results: &mut AnalysisResults,
1038) {
1039    let categories = ctx.config.security.categories.as_ref();
1040    let filter = security::CategoryFilter::new(
1041        categories.and_then(|c| c.include.clone()),
1042        categories.and_then(|c| c.exclude.clone()),
1043    );
1044    let (sink_findings, sink_stats) = security::find_tainted_sinks(
1045        ctx.graph,
1046        ctx.modules,
1047        ctx.suppressions,
1048        ctx.line_offsets_by_file,
1049        ctx.declared_deps,
1050        &security::TaintedSinkContext {
1051            category_filter: &filter,
1052            request_receivers: ctx.request_receivers,
1053            root: &ctx.config.root,
1054        },
1055    );
1056    results.security_findings.extend(sink_findings);
1057    results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
1058    results.security_unresolved_callee_diagnostics = sink_stats.unresolved_callee_diagnostics;
1059    results
1060        .security_findings
1061        .extend(security::find_hardcoded_secret_candidates(
1062            ctx.graph,
1063            ctx.modules,
1064            ctx.suppressions,
1065            ctx.line_offsets_by_file,
1066            &filter,
1067            &ctx.config.root,
1068        ));
1069}
1070
1071fn annotate_security_findings(
1072    ctx: &SecurityDetectionContext<'_, '_>,
1073    results: &mut AnalysisResults,
1074) {
1075    security::annotate_dead_code_cross_links(
1076        ctx.graph,
1077        ctx.modules,
1078        ctx.line_offsets_by_file,
1079        &results.unused_files,
1080        &results.unused_exports,
1081        &mut results.security_findings,
1082    );
1083    let boundary_crossings = boundary_crossings_by_file(&results.boundary_violations);
1084    security::rank_security_findings(
1085        ctx.graph,
1086        ctx.modules,
1087        ctx.line_offsets_by_file,
1088        ctx.declared_deps,
1089        ctx.request_receivers,
1090        &boundary_crossings,
1091        &mut results.security_findings,
1092    );
1093}
1094
1095fn boundary_crossings_by_file(
1096    boundary_violations: &[BoundaryViolationFinding],
1097) -> FxHashMap<std::path::PathBuf, (String, String)> {
1098    let mut boundary_crossings: FxHashMap<std::path::PathBuf, (String, String)> =
1099        FxHashMap::default();
1100    for violation in boundary_violations {
1101        let zones = (
1102            violation.violation.from_zone.clone(),
1103            violation.violation.to_zone.clone(),
1104        );
1105        for path in [
1106            violation.violation.from_path.clone(),
1107            violation.violation.to_path.clone(),
1108        ] {
1109            boundary_crossings
1110                .entry(path)
1111                .and_modify(|existing| {
1112                    if zones < *existing {
1113                        *existing = zones.clone();
1114                    }
1115                })
1116                .or_insert_with(|| zones.clone());
1117        }
1118    }
1119    boundary_crossings
1120}
1121
1122#[expect(
1123    deprecated,
1124    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1125)]
1126fn run_unused_file_detector(
1127    graph: &ModuleGraph,
1128    config: &ResolvedConfig,
1129    suppressions: &crate::suppress::SuppressionContext<'_>,
1130) -> Vec<UnusedFileFinding> {
1131    if config.rules.unused_files == Severity::Off {
1132        return Vec::new();
1133    }
1134    find_unused_files(graph, suppressions)
1135        .into_iter()
1136        .map(UnusedFileFinding::with_actions)
1137        .collect()
1138}
1139
1140#[expect(
1141    deprecated,
1142    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1143)]
1144fn run_export_detectors(
1145    graph: &ModuleGraph,
1146    modules: &[ModuleInfo],
1147    config: &ResolvedConfig,
1148    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
1149    suppressions: &crate::suppress::SuppressionContext<'_>,
1150    line_offsets_by_file: &LineOffsetsMap<'_>,
1151) -> AnalysisResults {
1152    let mut results = AnalysisResults::default();
1153    if config.rules.unused_exports == Severity::Off
1154        && config.rules.unused_types == Severity::Off
1155        && config.rules.private_type_leaks == Severity::Off
1156    {
1157        return results;
1158    }
1159
1160    let (exports, types, stale_expected) = find_unused_exports(
1161        graph,
1162        modules,
1163        config,
1164        plugin_result,
1165        suppressions,
1166        line_offsets_by_file,
1167    );
1168    if config.rules.unused_exports != Severity::Off {
1169        results.unused_exports = exports
1170            .into_iter()
1171            .map(UnusedExportFinding::with_actions)
1172            .collect();
1173    }
1174    if config.rules.unused_types != Severity::Off {
1175        let mut typed = types;
1176        suppress_signature_backing_types(&mut typed, graph, modules);
1177        results.unused_types = typed
1178            .into_iter()
1179            .map(UnusedTypeFinding::with_actions)
1180            .collect();
1181    }
1182    if config.rules.private_type_leaks != Severity::Off {
1183        results.private_type_leaks =
1184            find_private_type_leaks(graph, modules, config, suppressions, line_offsets_by_file)
1185                .into_iter()
1186                .map(PrivateTypeLeakFinding::with_actions)
1187                .collect();
1188    }
1189    if config.rules.stale_suppressions != Severity::Off {
1190        results.stale_suppressions.extend(stale_expected);
1191    }
1192    results
1193}
1194
1195#[expect(
1196    clippy::too_many_arguments,
1197    reason = "member detection needs graph context plus public API and allowlist filters"
1198)]
1199fn run_member_detectors(
1200    graph: &ModuleGraph,
1201    resolved_modules: &[ResolvedModule],
1202    modules: &[ModuleInfo],
1203    config: &ResolvedConfig,
1204    suppressions: &crate::suppress::SuppressionContext<'_>,
1205    line_offsets_by_file: &LineOffsetsMap<'_>,
1206    user_class_members: &[fallow_config::UsedClassMemberRule],
1207    public_api_entry_points: &FxHashSet<FileId>,
1208) -> AnalysisResults {
1209    let mut results = AnalysisResults::default();
1210    if config.rules.unused_enum_members == Severity::Off
1211        && config.rules.unused_class_members == Severity::Off
1212    {
1213        return results;
1214    }
1215
1216    let (enum_members, class_members) = find_unused_members_with_public_api_entry_points(
1217        graph,
1218        resolved_modules,
1219        modules,
1220        suppressions,
1221        line_offsets_by_file,
1222        user_class_members,
1223        &config.ignore_decorators,
1224        public_api_entry_points,
1225    );
1226    if config.rules.unused_enum_members != Severity::Off {
1227        results.unused_enum_members = enum_members
1228            .into_iter()
1229            .map(UnusedEnumMemberFinding::with_actions)
1230            .collect();
1231    }
1232    if config.rules.unused_class_members != Severity::Off {
1233        results.unused_class_members = class_members
1234            .into_iter()
1235            .map(UnusedClassMemberFinding::with_actions)
1236            .collect();
1237    }
1238    results
1239}
1240
1241#[expect(
1242    deprecated,
1243    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1244)]
1245fn run_dependency_detectors(
1246    graph: &ModuleGraph,
1247    pkg: Option<&PackageJson>,
1248    config: &ResolvedConfig,
1249    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
1250    workspaces: &[fallow_config::WorkspaceInfo],
1251    resolved_modules: &[ResolvedModule],
1252    line_offsets_by_file: &LineOffsetsMap<'_>,
1253) -> AnalysisResults {
1254    let mut results = AnalysisResults::default();
1255    let Some(pkg) = pkg else {
1256        return results;
1257    };
1258
1259    if config.rules.unused_dependencies != Severity::Off
1260        || config.rules.unused_dev_dependencies != Severity::Off
1261        || config.rules.unused_optional_dependencies != Severity::Off
1262    {
1263        let (deps, dev_deps, optional_deps) =
1264            find_unused_dependencies(graph, pkg, config, plugin_result, workspaces);
1265        if config.rules.unused_dependencies != Severity::Off {
1266            results.unused_dependencies = deps
1267                .into_iter()
1268                .map(UnusedDependencyFinding::with_actions)
1269                .collect();
1270        }
1271        if config.rules.unused_dev_dependencies != Severity::Off {
1272            results.unused_dev_dependencies = dev_deps
1273                .into_iter()
1274                .map(UnusedDevDependencyFinding::with_actions)
1275                .collect();
1276        }
1277        if config.rules.unused_optional_dependencies != Severity::Off {
1278            results.unused_optional_dependencies = optional_deps
1279                .into_iter()
1280                .map(UnusedOptionalDependencyFinding::with_actions)
1281                .collect();
1282        }
1283    }
1284
1285    if config.rules.unlisted_dependencies != Severity::Off {
1286        results.unlisted_dependencies = find_unlisted_dependencies(
1287            graph,
1288            pkg,
1289            config,
1290            workspaces,
1291            plugin_result,
1292            resolved_modules,
1293            line_offsets_by_file,
1294        )
1295        .into_iter()
1296        .map(UnlistedDependencyFinding::with_actions)
1297        .collect();
1298    }
1299
1300    if config.production {
1301        results.type_only_dependencies =
1302            find_type_only_dependencies(graph, pkg, config, workspaces)
1303                .into_iter()
1304                .map(TypeOnlyDependencyFinding::with_actions)
1305                .collect();
1306    }
1307
1308    if !config.production && config.rules.test_only_dependencies != Severity::Off {
1309        results.test_only_dependencies =
1310            find_test_only_dependencies(graph, pkg, config, workspaces)
1311                .into_iter()
1312                .map(TestOnlyDependencyFinding::with_actions)
1313                .collect();
1314    }
1315    results
1316}
1317
1318fn run_unresolved_import_detector(
1319    resolved_modules: &[ResolvedModule],
1320    config: &ResolvedConfig,
1321    suppressions: &crate::suppress::SuppressionContext<'_>,
1322    virtual_prefixes: &[&str],
1323    generated_patterns: &[&str],
1324    generated_type_prefixes: &[&str],
1325    line_offsets_by_file: &LineOffsetsMap<'_>,
1326) -> Vec<UnresolvedImportFinding> {
1327    if config.rules.unresolved_imports == Severity::Off || resolved_modules.is_empty() {
1328        return Vec::new();
1329    }
1330    find_unresolved_imports(
1331        resolved_modules,
1332        config,
1333        suppressions,
1334        virtual_prefixes,
1335        generated_patterns,
1336        generated_type_prefixes,
1337        line_offsets_by_file,
1338    )
1339    .into_iter()
1340    .map(UnresolvedImportFinding::with_actions)
1341    .collect()
1342}
1343
1344#[cfg(test)]
1345#[expect(
1346    deprecated,
1347    reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
1348)]
1349mod tests {
1350    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
1351
1352    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
1353        let offsets = compute_line_offsets(source);
1354        byte_offset_to_line_col(&offsets, byte_offset)
1355    }
1356
1357    #[test]
1358    fn compute_offsets_empty() {
1359        assert_eq!(compute_line_offsets(""), vec![0]);
1360    }
1361
1362    #[test]
1363    fn compute_offsets_single_line() {
1364        assert_eq!(compute_line_offsets("hello"), vec![0]);
1365    }
1366
1367    #[test]
1368    fn compute_offsets_multiline() {
1369        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
1370    }
1371
1372    #[test]
1373    fn compute_offsets_trailing_newline() {
1374        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
1375    }
1376
1377    #[test]
1378    fn compute_offsets_crlf() {
1379        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
1380    }
1381
1382    #[test]
1383    fn compute_offsets_consecutive_newlines() {
1384        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
1385    }
1386
1387    #[test]
1388    fn byte_offset_empty_source() {
1389        assert_eq!(line_col("", 0), (1, 0));
1390    }
1391
1392    #[test]
1393    fn byte_offset_single_line_start() {
1394        assert_eq!(line_col("hello", 0), (1, 0));
1395    }
1396
1397    #[test]
1398    fn byte_offset_single_line_middle() {
1399        assert_eq!(line_col("hello", 4), (1, 4));
1400    }
1401
1402    #[test]
1403    fn byte_offset_multiline_start_of_line2() {
1404        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
1405    }
1406
1407    #[test]
1408    fn byte_offset_multiline_middle_of_line3() {
1409        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
1410    }
1411
1412    #[test]
1413    fn byte_offset_at_newline_boundary() {
1414        assert_eq!(line_col("line1\nline2", 5), (1, 5));
1415    }
1416
1417    #[test]
1418    fn byte_offset_multibyte_utf8() {
1419        let source = "hi\n\u{1F600}x";
1420        assert_eq!(line_col(source, 3), (2, 0));
1421        assert_eq!(line_col(source, 7), (2, 4));
1422    }
1423
1424    #[test]
1425    fn byte_offset_multibyte_accented_chars() {
1426        let source = "caf\u{00E9}\nbar";
1427        assert_eq!(line_col(source, 6), (2, 0));
1428        assert_eq!(line_col(source, 3), (1, 3));
1429    }
1430
1431    #[test]
1432    fn byte_offset_via_map_fallback() {
1433        use super::*;
1434        let map: LineOffsetsMap<'_> = FxHashMap::default();
1435        assert_eq!(
1436            super::byte_offset_to_line_col(&map, FileId(99), 42),
1437            (1, 42)
1438        );
1439    }
1440
1441    #[test]
1442    fn byte_offset_via_map_lookup() {
1443        use super::*;
1444        let offsets = compute_line_offsets("abc\ndef\nghi");
1445        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1446        map.insert(FileId(0), &offsets);
1447        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1448    }
1449
1450    mod orchestration {
1451        use super::super::*;
1452        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1453        use std::path::PathBuf;
1454
1455        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1456            find_dead_code_full(graph, config, &[], None, &[], &[], false)
1457        }
1458
1459        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1460            FallowConfig {
1461                rules,
1462                ..Default::default()
1463            }
1464            .resolve(
1465                PathBuf::from("/tmp/orchestration-test"),
1466                OutputFormat::Human,
1467                1,
1468                true,
1469                true,
1470                None,
1471            )
1472        }
1473
1474        #[test]
1475        fn find_dead_code_all_rules_off_returns_empty() {
1476            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1477            use crate::graph::ModuleGraph;
1478            use crate::resolve::ResolvedModule;
1479            use rustc_hash::FxHashSet;
1480
1481            let files = vec![DiscoveredFile {
1482                id: FileId(0),
1483                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1484                size_bytes: 100,
1485            }];
1486            let entry_points = vec![EntryPoint {
1487                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1488                source: EntryPointSource::ManualEntry,
1489            }];
1490            let resolved = vec![ResolvedModule {
1491                file_id: FileId(0),
1492                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1493                exports: vec![],
1494                re_exports: vec![],
1495                resolved_imports: vec![],
1496                resolved_dynamic_imports: vec![],
1497                resolved_dynamic_patterns: vec![],
1498                member_accesses: vec![],
1499                whole_object_uses: vec![],
1500                has_cjs_exports: false,
1501                has_angular_component_template_url: false,
1502                unused_import_bindings: FxHashSet::default(),
1503                type_referenced_import_bindings: vec![],
1504                value_referenced_import_bindings: vec![],
1505                namespace_object_aliases: vec![],
1506            }];
1507            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1508
1509            let rules = RulesConfig {
1510                unused_files: Severity::Off,
1511                unused_exports: Severity::Off,
1512                unused_types: Severity::Off,
1513                private_type_leaks: Severity::Off,
1514                unused_dependencies: Severity::Off,
1515                unused_dev_dependencies: Severity::Off,
1516                unused_optional_dependencies: Severity::Off,
1517                unused_enum_members: Severity::Off,
1518                unused_class_members: Severity::Off,
1519                unresolved_imports: Severity::Off,
1520                unlisted_dependencies: Severity::Off,
1521                duplicate_exports: Severity::Off,
1522                type_only_dependencies: Severity::Off,
1523                circular_dependencies: Severity::Off,
1524                re_export_cycle: Severity::Off,
1525                test_only_dependencies: Severity::Off,
1526                boundary_violation: Severity::Off,
1527                coverage_gaps: Severity::Off,
1528                feature_flags: Severity::Off,
1529                stale_suppressions: Severity::Off,
1530                unused_catalog_entries: Severity::Off,
1531                empty_catalog_groups: Severity::Off,
1532                unresolved_catalog_references: Severity::Off,
1533                unused_dependency_overrides: Severity::Off,
1534                misconfigured_dependency_overrides: Severity::Off,
1535                security_client_server_leak: Severity::Off,
1536                security_sink: Severity::Off,
1537                policy_violation: Severity::Off,
1538            };
1539            let config = make_config_with_rules(rules);
1540            let results = find_dead_code(&graph, &config);
1541
1542            assert!(results.unused_files.is_empty());
1543            assert!(results.unused_exports.is_empty());
1544            assert!(results.unused_types.is_empty());
1545            assert!(results.unused_dependencies.is_empty());
1546            assert!(results.unused_dev_dependencies.is_empty());
1547            assert!(results.unused_optional_dependencies.is_empty());
1548            assert!(results.unused_enum_members.is_empty());
1549            assert!(results.unused_class_members.is_empty());
1550            assert!(results.unresolved_imports.is_empty());
1551            assert!(results.unlisted_dependencies.is_empty());
1552            assert!(results.duplicate_exports.is_empty());
1553            assert!(results.circular_dependencies.is_empty());
1554            assert!(results.export_usages.is_empty());
1555        }
1556
1557        #[test]
1558        fn find_dead_code_full_collect_usages_flag() {
1559            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1560            use crate::extract::{ExportName, VisibilityTag};
1561            use crate::graph::{ExportSymbol, ModuleGraph};
1562            use crate::resolve::ResolvedModule;
1563            use oxc_span::Span;
1564            use rustc_hash::FxHashSet;
1565
1566            let files = vec![DiscoveredFile {
1567                id: FileId(0),
1568                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1569                size_bytes: 100,
1570            }];
1571            let entry_points = vec![EntryPoint {
1572                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1573                source: EntryPointSource::ManualEntry,
1574            }];
1575            let resolved = vec![ResolvedModule {
1576                file_id: FileId(0),
1577                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1578                exports: vec![],
1579                re_exports: vec![],
1580                resolved_imports: vec![],
1581                resolved_dynamic_imports: vec![],
1582                resolved_dynamic_patterns: vec![],
1583                member_accesses: vec![],
1584                whole_object_uses: vec![],
1585                has_cjs_exports: false,
1586                has_angular_component_template_url: false,
1587                unused_import_bindings: FxHashSet::default(),
1588                type_referenced_import_bindings: vec![],
1589                value_referenced_import_bindings: vec![],
1590                namespace_object_aliases: vec![],
1591            }];
1592            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1593            graph.modules[0].exports = vec![ExportSymbol {
1594                name: ExportName::Named("myExport".to_string()),
1595                is_type_only: false,
1596                is_side_effect_used: false,
1597                visibility: VisibilityTag::None,
1598                span: Span::new(10, 30),
1599                references: vec![],
1600                members: vec![],
1601            }];
1602
1603            let rules = RulesConfig::default();
1604            let config = make_config_with_rules(rules);
1605
1606            let results_no_collect = find_dead_code_full(
1607                &graph,
1608                &config,
1609                &[],
1610                None,
1611                &[],
1612                &[],
1613                false, // collect_usages = false
1614            );
1615            assert!(
1616                results_no_collect.export_usages.is_empty(),
1617                "export_usages should be empty when collect_usages is false"
1618            );
1619
1620            let results_with_collect = find_dead_code_full(
1621                &graph,
1622                &config,
1623                &[],
1624                None,
1625                &[],
1626                &[],
1627                true, // collect_usages = true
1628            );
1629            assert!(
1630                !results_with_collect.export_usages.is_empty(),
1631                "export_usages should be populated when collect_usages is true"
1632            );
1633            assert_eq!(
1634                results_with_collect.export_usages[0].export_name,
1635                "myExport"
1636            );
1637        }
1638
1639        #[test]
1640        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1641            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1642            use crate::graph::ModuleGraph;
1643            use crate::resolve::ResolvedModule;
1644            use rustc_hash::FxHashSet;
1645
1646            let files = vec![DiscoveredFile {
1647                id: FileId(0),
1648                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1649                size_bytes: 100,
1650            }];
1651            let entry_points = vec![EntryPoint {
1652                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1653                source: EntryPointSource::ManualEntry,
1654            }];
1655            let resolved = vec![ResolvedModule {
1656                file_id: FileId(0),
1657                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1658                exports: vec![],
1659                re_exports: vec![],
1660                resolved_imports: vec![],
1661                resolved_dynamic_imports: vec![],
1662                resolved_dynamic_patterns: vec![],
1663                member_accesses: vec![],
1664                whole_object_uses: vec![],
1665                has_cjs_exports: false,
1666                has_angular_component_template_url: false,
1667                unused_import_bindings: FxHashSet::default(),
1668                type_referenced_import_bindings: vec![],
1669                value_referenced_import_bindings: vec![],
1670                namespace_object_aliases: vec![],
1671            }];
1672            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1673            let config = make_config_with_rules(RulesConfig::default());
1674
1675            let results = find_dead_code(&graph, &config);
1676            assert!(results.unused_exports.is_empty());
1677        }
1678
1679        #[test]
1680        fn suppressions_built_from_modules() {
1681            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1682            use crate::extract::ModuleInfo;
1683            use crate::graph::ModuleGraph;
1684            use crate::resolve::ResolvedModule;
1685            use crate::suppress::{IssueKind, Suppression};
1686            use rustc_hash::FxHashSet;
1687
1688            let files = vec![
1689                DiscoveredFile {
1690                    id: FileId(0),
1691                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1692                    size_bytes: 100,
1693                },
1694                DiscoveredFile {
1695                    id: FileId(1),
1696                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1697                    size_bytes: 100,
1698                },
1699            ];
1700            let entry_points = vec![EntryPoint {
1701                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1702                source: EntryPointSource::ManualEntry,
1703            }];
1704            let resolved = files
1705                .iter()
1706                .map(|f| ResolvedModule {
1707                    file_id: f.id,
1708                    path: f.path.clone(),
1709                    exports: vec![],
1710                    re_exports: vec![],
1711                    resolved_imports: vec![],
1712                    resolved_dynamic_imports: vec![],
1713                    resolved_dynamic_patterns: vec![],
1714                    member_accesses: vec![],
1715                    whole_object_uses: vec![],
1716                    has_cjs_exports: false,
1717                    has_angular_component_template_url: false,
1718                    unused_import_bindings: FxHashSet::default(),
1719                    type_referenced_import_bindings: vec![],
1720                    value_referenced_import_bindings: vec![],
1721                    namespace_object_aliases: vec![],
1722                })
1723                .collect::<Vec<_>>();
1724            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1725
1726            let modules = vec![ModuleInfo {
1727                file_id: FileId(1),
1728                exports: vec![],
1729                imports: vec![],
1730                re_exports: vec![],
1731                dynamic_imports: vec![],
1732                dynamic_import_patterns: vec![],
1733                require_calls: vec![],
1734                package_path_references: vec![],
1735                member_accesses: vec![],
1736                whole_object_uses: vec![],
1737                has_cjs_exports: false,
1738                has_angular_component_template_url: false,
1739                content_hash: 0,
1740                suppressions: vec![Suppression {
1741                    line: 0,
1742                    comment_line: 1,
1743                    kind: Some(IssueKind::UnusedFile),
1744                }],
1745                unknown_suppression_kinds: vec![],
1746                unused_import_bindings: vec![],
1747                type_referenced_import_bindings: vec![],
1748                value_referenced_import_bindings: vec![],
1749                line_offsets: vec![],
1750                complexity: vec![],
1751                flag_uses: vec![],
1752                class_heritage: vec![],
1753                injection_tokens: vec![],
1754                local_type_declarations: Vec::new(),
1755                public_signature_type_references: Vec::new(),
1756                namespace_object_aliases: Vec::new(),
1757                iconify_prefixes: Vec::new(),
1758                iconify_icon_names: Vec::new(),
1759                auto_import_candidates: Vec::new(),
1760                directives: Vec::new(),
1761                security_sinks: Vec::new(),
1762                security_sinks_skipped: 0,
1763                security_unresolved_callee_sites: Vec::new(),
1764                tainted_bindings: Vec::new(),
1765                sanitized_sink_args: Vec::new(),
1766                security_control_sites: Vec::new(),
1767                callee_uses: Vec::new(),
1768            }];
1769
1770            let rules = RulesConfig {
1771                unused_files: Severity::Error,
1772                ..RulesConfig::default()
1773            };
1774            let config = make_config_with_rules(rules);
1775
1776            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1777
1778            assert!(
1779                !results.unused_files.iter().any(|f| f
1780                    .file
1781                    .path
1782                    .to_string_lossy()
1783                    .contains("utils.ts")),
1784                "suppressed file should not appear in unused_files"
1785            );
1786        }
1787    }
1788}