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