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
853    // Detect pnpm catalog issues (purely off package.json + pnpm-workspace.yaml).
854    // Catalog detectors share the YAML parse and consumer walk; gather state
855    // once and run each detector gated on its own rule severity.
856    let need_unused_catalogs = config.rules.unused_catalog_entries != Severity::Off;
857    let need_empty_catalog_groups = config.rules.empty_catalog_groups != Severity::Off;
858    let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
859    if (need_unused_catalogs || need_empty_catalog_groups || need_unresolved_refs)
860        && let Some(state) = gather_pnpm_catalog_state(config, workspaces)
861    {
862        if need_unused_catalogs {
863            results.unused_catalog_entries = find_unused_catalog_entries(&state)
864                .into_iter()
865                .map(UnusedCatalogEntryFinding::with_actions)
866                .collect();
867        }
868        if need_empty_catalog_groups {
869            results.empty_catalog_groups = find_empty_catalog_groups(&state)
870                .into_iter()
871                .map(EmptyCatalogGroupFinding::with_actions)
872                .collect();
873        }
874        if need_unresolved_refs {
875            results.unresolved_catalog_references = find_unresolved_catalog_references(
876                &state,
877                &config.compiled_ignore_catalog_references,
878                &config.root,
879            )
880            .into_iter()
881            .map(UnresolvedCatalogReferenceFinding::with_actions)
882            .collect();
883        }
884    }
885
886    // Detect pnpm dependency-override issues (off pnpm-workspace.yaml +
887    // root package.json's pnpm.overrides). Mirrors the catalog detector: one
888    // parse + workspace walk feeds both unused-dependency-overrides and
889    // misconfigured-dependency-overrides; each detector gated on its own
890    // rule severity.
891    let need_unused_overrides = config.rules.unused_dependency_overrides != Severity::Off;
892    let need_misconfigured_overrides =
893        config.rules.misconfigured_dependency_overrides != Severity::Off;
894    if (need_unused_overrides || need_misconfigured_overrides)
895        && let Some(state) = gather_pnpm_override_state(config, workspaces)
896    {
897        if need_unused_overrides {
898            results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
899                .into_iter()
900                .map(UnusedDependencyOverrideFinding::with_actions)
901                .collect();
902        }
903        if need_misconfigured_overrides {
904            results.misconfigured_dependency_overrides =
905                find_misconfigured_dependency_overrides(&state, config)
906                    .into_iter()
907                    .map(MisconfiguredDependencyOverrideFinding::with_actions)
908                    .collect();
909        }
910    }
911
912    // Sort all result arrays for deterministic output ordering.
913    // Parallel collection and FxHashMap iteration don't guarantee order,
914    // so without sorting the same project can produce different orderings.
915    results.sort();
916
917    results
918}
919
920#[cfg(test)]
921#[expect(
922    deprecated,
923    reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
924)]
925mod tests {
926    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
927
928    // Helper: compute line offsets from source and convert byte offset
929    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
930        let offsets = compute_line_offsets(source);
931        byte_offset_to_line_col(&offsets, byte_offset)
932    }
933
934    // ── compute_line_offsets ─────────────────────────────────────
935
936    #[test]
937    fn compute_offsets_empty() {
938        assert_eq!(compute_line_offsets(""), vec![0]);
939    }
940
941    #[test]
942    fn compute_offsets_single_line() {
943        assert_eq!(compute_line_offsets("hello"), vec![0]);
944    }
945
946    #[test]
947    fn compute_offsets_multiline() {
948        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
949    }
950
951    #[test]
952    fn compute_offsets_trailing_newline() {
953        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
954    }
955
956    #[test]
957    fn compute_offsets_crlf() {
958        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
959    }
960
961    #[test]
962    fn compute_offsets_consecutive_newlines() {
963        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
964    }
965
966    // ── byte_offset_to_line_col ─────────────────────────────────
967
968    #[test]
969    fn byte_offset_empty_source() {
970        assert_eq!(line_col("", 0), (1, 0));
971    }
972
973    #[test]
974    fn byte_offset_single_line_start() {
975        assert_eq!(line_col("hello", 0), (1, 0));
976    }
977
978    #[test]
979    fn byte_offset_single_line_middle() {
980        assert_eq!(line_col("hello", 4), (1, 4));
981    }
982
983    #[test]
984    fn byte_offset_multiline_start_of_line2() {
985        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
986    }
987
988    #[test]
989    fn byte_offset_multiline_middle_of_line3() {
990        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
991    }
992
993    #[test]
994    fn byte_offset_at_newline_boundary() {
995        assert_eq!(line_col("line1\nline2", 5), (1, 5));
996    }
997
998    #[test]
999    fn byte_offset_multibyte_utf8() {
1000        let source = "hi\n\u{1F600}x";
1001        assert_eq!(line_col(source, 3), (2, 0));
1002        assert_eq!(line_col(source, 7), (2, 4));
1003    }
1004
1005    #[test]
1006    fn byte_offset_multibyte_accented_chars() {
1007        let source = "caf\u{00E9}\nbar";
1008        assert_eq!(line_col(source, 6), (2, 0));
1009        assert_eq!(line_col(source, 3), (1, 3));
1010    }
1011
1012    #[test]
1013    fn byte_offset_via_map_fallback() {
1014        use super::*;
1015        let map: LineOffsetsMap<'_> = FxHashMap::default();
1016        assert_eq!(
1017            super::byte_offset_to_line_col(&map, FileId(99), 42),
1018            (1, 42)
1019        );
1020    }
1021
1022    #[test]
1023    fn byte_offset_via_map_lookup() {
1024        use super::*;
1025        let offsets = compute_line_offsets("abc\ndef\nghi");
1026        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
1027        map.insert(FileId(0), &offsets);
1028        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
1029    }
1030
1031    // ── find_dead_code orchestration ──────────────────────────────
1032
1033    mod orchestration {
1034        use super::super::*;
1035        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
1036        use std::path::PathBuf;
1037
1038        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
1039            find_dead_code_full(graph, config, &[], None, &[], &[], false)
1040        }
1041
1042        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
1043            FallowConfig {
1044                rules,
1045                ..Default::default()
1046            }
1047            .resolve(
1048                PathBuf::from("/tmp/orchestration-test"),
1049                OutputFormat::Human,
1050                1,
1051                true,
1052                true,
1053                None,
1054            )
1055        }
1056
1057        #[test]
1058        fn find_dead_code_all_rules_off_returns_empty() {
1059            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1060            use crate::graph::ModuleGraph;
1061            use crate::resolve::ResolvedModule;
1062            use rustc_hash::FxHashSet;
1063
1064            let files = vec![DiscoveredFile {
1065                id: FileId(0),
1066                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1067                size_bytes: 100,
1068            }];
1069            let entry_points = vec![EntryPoint {
1070                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1071                source: EntryPointSource::ManualEntry,
1072            }];
1073            let resolved = vec![ResolvedModule {
1074                file_id: FileId(0),
1075                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1076                exports: vec![],
1077                re_exports: vec![],
1078                resolved_imports: vec![],
1079                resolved_dynamic_imports: vec![],
1080                resolved_dynamic_patterns: vec![],
1081                member_accesses: vec![],
1082                whole_object_uses: vec![],
1083                has_cjs_exports: false,
1084                has_angular_component_template_url: false,
1085                unused_import_bindings: FxHashSet::default(),
1086                type_referenced_import_bindings: vec![],
1087                value_referenced_import_bindings: vec![],
1088                namespace_object_aliases: vec![],
1089            }];
1090            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1091
1092            let rules = RulesConfig {
1093                unused_files: Severity::Off,
1094                unused_exports: Severity::Off,
1095                unused_types: Severity::Off,
1096                private_type_leaks: Severity::Off,
1097                unused_dependencies: Severity::Off,
1098                unused_dev_dependencies: Severity::Off,
1099                unused_optional_dependencies: Severity::Off,
1100                unused_enum_members: Severity::Off,
1101                unused_class_members: Severity::Off,
1102                unresolved_imports: Severity::Off,
1103                unlisted_dependencies: Severity::Off,
1104                duplicate_exports: Severity::Off,
1105                type_only_dependencies: Severity::Off,
1106                circular_dependencies: Severity::Off,
1107                re_export_cycle: Severity::Off,
1108                test_only_dependencies: Severity::Off,
1109                boundary_violation: Severity::Off,
1110                coverage_gaps: Severity::Off,
1111                feature_flags: Severity::Off,
1112                stale_suppressions: Severity::Off,
1113                unused_catalog_entries: Severity::Off,
1114                empty_catalog_groups: Severity::Off,
1115                unresolved_catalog_references: Severity::Off,
1116                unused_dependency_overrides: Severity::Off,
1117                misconfigured_dependency_overrides: Severity::Off,
1118            };
1119            let config = make_config_with_rules(rules);
1120            let results = find_dead_code(&graph, &config);
1121
1122            assert!(results.unused_files.is_empty());
1123            assert!(results.unused_exports.is_empty());
1124            assert!(results.unused_types.is_empty());
1125            assert!(results.unused_dependencies.is_empty());
1126            assert!(results.unused_dev_dependencies.is_empty());
1127            assert!(results.unused_optional_dependencies.is_empty());
1128            assert!(results.unused_enum_members.is_empty());
1129            assert!(results.unused_class_members.is_empty());
1130            assert!(results.unresolved_imports.is_empty());
1131            assert!(results.unlisted_dependencies.is_empty());
1132            assert!(results.duplicate_exports.is_empty());
1133            assert!(results.circular_dependencies.is_empty());
1134            assert!(results.export_usages.is_empty());
1135        }
1136
1137        #[test]
1138        fn find_dead_code_full_collect_usages_flag() {
1139            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1140            use crate::extract::{ExportName, VisibilityTag};
1141            use crate::graph::{ExportSymbol, ModuleGraph};
1142            use crate::resolve::ResolvedModule;
1143            use oxc_span::Span;
1144            use rustc_hash::FxHashSet;
1145
1146            let files = vec![DiscoveredFile {
1147                id: FileId(0),
1148                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1149                size_bytes: 100,
1150            }];
1151            let entry_points = vec![EntryPoint {
1152                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1153                source: EntryPointSource::ManualEntry,
1154            }];
1155            let resolved = vec![ResolvedModule {
1156                file_id: FileId(0),
1157                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1158                exports: vec![],
1159                re_exports: vec![],
1160                resolved_imports: vec![],
1161                resolved_dynamic_imports: vec![],
1162                resolved_dynamic_patterns: vec![],
1163                member_accesses: vec![],
1164                whole_object_uses: vec![],
1165                has_cjs_exports: false,
1166                has_angular_component_template_url: false,
1167                unused_import_bindings: FxHashSet::default(),
1168                type_referenced_import_bindings: vec![],
1169                value_referenced_import_bindings: vec![],
1170                namespace_object_aliases: vec![],
1171            }];
1172            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
1173            graph.modules[0].exports = vec![ExportSymbol {
1174                name: ExportName::Named("myExport".to_string()),
1175                is_type_only: false,
1176                is_side_effect_used: false,
1177                visibility: VisibilityTag::None,
1178                span: Span::new(10, 30),
1179                references: vec![],
1180                members: vec![],
1181            }];
1182
1183            let rules = RulesConfig::default();
1184            let config = make_config_with_rules(rules);
1185
1186            // Without collect_usages
1187            let results_no_collect = find_dead_code_full(
1188                &graph,
1189                &config,
1190                &[],
1191                None,
1192                &[],
1193                &[],
1194                false, // collect_usages = false
1195            );
1196            assert!(
1197                results_no_collect.export_usages.is_empty(),
1198                "export_usages should be empty when collect_usages is false"
1199            );
1200
1201            // With collect_usages
1202            let results_with_collect = find_dead_code_full(
1203                &graph,
1204                &config,
1205                &[],
1206                None,
1207                &[],
1208                &[],
1209                true, // collect_usages = true
1210            );
1211            assert!(
1212                !results_with_collect.export_usages.is_empty(),
1213                "export_usages should be populated when collect_usages is true"
1214            );
1215            assert_eq!(
1216                results_with_collect.export_usages[0].export_name,
1217                "myExport"
1218            );
1219        }
1220
1221        #[test]
1222        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
1223            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1224            use crate::graph::ModuleGraph;
1225            use crate::resolve::ResolvedModule;
1226            use rustc_hash::FxHashSet;
1227
1228            let files = vec![DiscoveredFile {
1229                id: FileId(0),
1230                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1231                size_bytes: 100,
1232            }];
1233            let entry_points = vec![EntryPoint {
1234                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1235                source: EntryPointSource::ManualEntry,
1236            }];
1237            let resolved = vec![ResolvedModule {
1238                file_id: FileId(0),
1239                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
1240                exports: vec![],
1241                re_exports: vec![],
1242                resolved_imports: vec![],
1243                resolved_dynamic_imports: vec![],
1244                resolved_dynamic_patterns: vec![],
1245                member_accesses: vec![],
1246                whole_object_uses: vec![],
1247                has_cjs_exports: false,
1248                has_angular_component_template_url: false,
1249                unused_import_bindings: FxHashSet::default(),
1250                type_referenced_import_bindings: vec![],
1251                value_referenced_import_bindings: vec![],
1252                namespace_object_aliases: vec![],
1253            }];
1254            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1255            let config = make_config_with_rules(RulesConfig::default());
1256
1257            // find_dead_code is a thin wrapper — verify it doesn't panic and returns results
1258            let results = find_dead_code(&graph, &config);
1259            // The entry point export analysis is skipped, so these should be empty
1260            assert!(results.unused_exports.is_empty());
1261        }
1262
1263        #[test]
1264        fn suppressions_built_from_modules() {
1265            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1266            use crate::extract::ModuleInfo;
1267            use crate::graph::ModuleGraph;
1268            use crate::resolve::ResolvedModule;
1269            use crate::suppress::{IssueKind, Suppression};
1270            use rustc_hash::FxHashSet;
1271
1272            let files = vec![
1273                DiscoveredFile {
1274                    id: FileId(0),
1275                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1276                    size_bytes: 100,
1277                },
1278                DiscoveredFile {
1279                    id: FileId(1),
1280                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
1281                    size_bytes: 100,
1282                },
1283            ];
1284            let entry_points = vec![EntryPoint {
1285                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
1286                source: EntryPointSource::ManualEntry,
1287            }];
1288            let resolved = files
1289                .iter()
1290                .map(|f| ResolvedModule {
1291                    file_id: f.id,
1292                    path: f.path.clone(),
1293                    exports: vec![],
1294                    re_exports: vec![],
1295                    resolved_imports: vec![],
1296                    resolved_dynamic_imports: vec![],
1297                    resolved_dynamic_patterns: vec![],
1298                    member_accesses: vec![],
1299                    whole_object_uses: vec![],
1300                    has_cjs_exports: false,
1301                    has_angular_component_template_url: false,
1302                    unused_import_bindings: FxHashSet::default(),
1303                    type_referenced_import_bindings: vec![],
1304                    value_referenced_import_bindings: vec![],
1305                    namespace_object_aliases: vec![],
1306                })
1307                .collect::<Vec<_>>();
1308            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1309
1310            // Create module info with a file-level suppression for unused files
1311            let modules = vec![ModuleInfo {
1312                file_id: FileId(1),
1313                exports: vec![],
1314                imports: vec![],
1315                re_exports: vec![],
1316                dynamic_imports: vec![],
1317                dynamic_import_patterns: vec![],
1318                require_calls: vec![],
1319                member_accesses: vec![],
1320                whole_object_uses: vec![],
1321                has_cjs_exports: false,
1322                has_angular_component_template_url: false,
1323                content_hash: 0,
1324                suppressions: vec![Suppression {
1325                    line: 0,
1326                    comment_line: 1,
1327                    kind: Some(IssueKind::UnusedFile),
1328                }],
1329                unknown_suppression_kinds: vec![],
1330                unused_import_bindings: vec![],
1331                type_referenced_import_bindings: vec![],
1332                value_referenced_import_bindings: vec![],
1333                line_offsets: vec![],
1334                complexity: vec![],
1335                flag_uses: vec![],
1336                class_heritage: vec![],
1337                local_type_declarations: Vec::new(),
1338                public_signature_type_references: Vec::new(),
1339                namespace_object_aliases: Vec::new(),
1340                iconify_prefixes: Vec::new(),
1341                auto_import_candidates: Vec::new(),
1342            }];
1343
1344            let rules = RulesConfig {
1345                unused_files: Severity::Error,
1346                ..RulesConfig::default()
1347            };
1348            let config = make_config_with_rules(rules);
1349
1350            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
1351
1352            // The suppression should prevent utils.ts from being reported as unused
1353            // (it would normally be unused since only entry.ts is an entry point).
1354            // Note: unused_files also checks if the file exists on disk, so it
1355            // may still be filtered out. The key is the suppression path is exercised.
1356            assert!(
1357                !results.unused_files.iter().any(|f| f
1358                    .file
1359                    .path
1360                    .to_string_lossy()
1361                    .contains("utils.ts")),
1362                "suppressed file should not appear in unused_files"
1363            );
1364        }
1365    }
1366}