Skip to main content

fallow_core/analyze/
mod.rs

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