Skip to main content

fallow_core/analyze/
mod.rs

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