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