Skip to main content

fallow_core/analyze/
mod.rs

1mod boundary;
2mod boundary_calls;
3mod boundary_coverage;
4mod duplicate_prop_shape;
5mod dynamic_segment_name_conflict;
6pub mod feature_flags;
7mod iconify;
8mod invalid_client_exports;
9mod misplaced_directive;
10mod mixed_barrel;
11mod package_json_utils;
12mod policy;
13mod predicates;
14mod prop_drilling;
15mod re_export_cycles;
16mod react_intel;
17mod react_resolve;
18mod render_fan_in;
19mod route_collision;
20mod route_tree;
21mod security;
22mod server_only;
23mod thin_wrapper;
24mod unprovided_inject;
25mod unrendered_component;
26mod unused_catalog;
27mod unused_component_emit;
28mod unused_component_input;
29mod unused_component_output;
30mod unused_component_prop;
31mod unused_deps;
32mod unused_exports;
33mod unused_files;
34mod unused_load_data_key;
35mod unused_members;
36mod unused_overrides;
37mod unused_server_action;
38mod unused_svelte_event;
39
40#[cfg(test)]
41pub(crate) mod test_support;
42
43#[cfg(test)]
44pub(crate) use unused_deps::matches_virtual_prefix;
45
46/// Human-readable title for a security catalogue category id, for the CLI
47/// renderer. Re-exported so the `fallow security` command can label a
48/// `TaintedSink` finding without reaching into the private `security` module.
49pub use security::catalogue_title as security_catalogue_title;
50pub use security::derive_security_severity;
51
52use rustc_hash::{FxHashMap, FxHashSet};
53
54use fallow_config::{PackageJson, ResolvedConfig, Severity};
55
56use crate::discover::FileId;
57use crate::extract::ModuleInfo;
58use crate::graph::ModuleGraph;
59use crate::resolve::ResolvedModule;
60use fallow_types::output_dead_code::{
61    BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
62    CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
63    DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
64    MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
65    MixedClientServerBarrelFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
66    PropDrillingChainFinding, ReExportCycleFinding, RouteCollisionFinding,
67    TestOnlyDependencyFinding, ThinWrapperFinding, TypeOnlyDependencyFinding,
68    UnlistedDependencyFinding, UnprovidedInjectFinding, UnrenderedComponentFinding,
69    UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
70    UnusedClassMemberFinding, UnusedComponentEmitFinding, UnusedComponentInputFinding,
71    UnusedComponentOutputFinding, UnusedComponentPropFinding, UnusedDependencyFinding,
72    UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
73    UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
74    UnusedOptionalDependencyFinding, UnusedStoreMemberFinding, UnusedSvelteEventFinding,
75    UnusedTypeFinding,
76};
77
78use crate::results::{
79    AnalysisResults, CircularDependency, CircularDependencyEdge, StaleSuppression,
80    UnusedDependency, UnusedExport, UnusedMember,
81};
82use crate::suppress::{IssueKind, SuppressionContext};
83
84use duplicate_prop_shape::find_duplicate_prop_shapes;
85use dynamic_segment_name_conflict::find_dynamic_segment_name_conflicts;
86use invalid_client_exports::find_invalid_client_exports;
87use misplaced_directive::find_misplaced_directives;
88use mixed_barrel::find_mixed_client_server_barrels;
89use prop_drilling::find_prop_drilling_chains;
90use re_export_cycles::find_re_export_cycles;
91use react_intel::compute_react_component_intel;
92use render_fan_in::compute_render_fan_in;
93use route_collision::find_route_collisions;
94use thin_wrapper::find_thin_wrappers;
95use unprovided_inject::{UnprovidedInjectInput, find_unprovided_injects};
96use unrendered_component::{
97    LitUnrenderedInput, find_unrendered_angular_components, find_unrendered_components,
98    find_unrendered_lit_elements,
99};
100#[expect(
101    deprecated,
102    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
103)]
104use unused_catalog::{
105    find_empty_catalog_groups, find_unresolved_catalog_references, find_unused_catalog_entries,
106    gather_pnpm_catalog_state,
107};
108use unused_component_emit::find_unused_component_emits;
109use unused_component_input::find_unused_component_inputs;
110use unused_component_output::find_unused_component_outputs;
111use unused_component_prop::{find_unused_component_props, find_unused_react_props};
112#[expect(
113    deprecated,
114    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
115)]
116use unused_deps::{
117    UnlistedDependencyInput, find_test_only_dependencies, find_type_only_dependencies,
118    find_unlisted_dependencies, find_unresolved_imports, find_unused_dependencies,
119};
120#[expect(
121    deprecated,
122    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
123)]
124use unused_exports::{
125    collect_export_usages, find_private_type_leaks, find_unused_exports,
126    suppress_signature_backing_types,
127};
128#[expect(
129    deprecated,
130    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
131)]
132use unused_files::find_unused_files;
133use unused_load_data_key::find_unused_load_data_keys;
134use unused_members::{UnusedMemberScanInput, find_unused_members_with_public_api_entry_points};
135#[expect(
136    deprecated,
137    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
138)]
139use unused_overrides::{
140    find_misconfigured_dependency_overrides, find_unused_dependency_overrides,
141    gather_pnpm_override_state,
142};
143use unused_server_action::reclassify_unused_server_actions;
144use unused_svelte_event::find_unused_svelte_events;
145
146/// Pre-computed line offset tables indexed by `FileId`, built during parse and
147/// carried through the cache. Eliminates redundant file reads during analysis.
148#[doc(hidden)]
149pub type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
150
151struct SecurityDetectionContext<'a, 'm> {
152    graph: &'a ModuleGraph,
153    modules: &'a [ModuleInfo],
154    config: &'a ResolvedConfig,
155    suppressions: &'a crate::suppress::SuppressionContext<'m>,
156    line_offsets_by_file: &'a LineOffsetsMap<'m>,
157    declared_deps: &'a FxHashSet<String>,
158    request_receivers: &'a FxHashSet<String>,
159}
160
161/// Convert a byte offset to (line, col) using pre-computed line offsets.
162/// Falls back to `(1, byte_offset)` when no line table is available.
163#[doc(hidden)]
164pub fn byte_offset_to_line_col(
165    line_offsets_map: &LineOffsetsMap<'_>,
166    file_id: FileId,
167    byte_offset: u32,
168) -> (u32, u32) {
169    line_offsets_map
170        .get(&file_id)
171        .map_or((1, byte_offset), |offsets| {
172            fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
173        })
174}
175
176fn cycle_edge_line_col(
177    graph: &ModuleGraph,
178    line_offsets_map: &LineOffsetsMap<'_>,
179    cycle: &[FileId],
180    edge_index: usize,
181) -> Option<(u32, u32)> {
182    if cycle.is_empty() {
183        return None;
184    }
185
186    let from = cycle[edge_index];
187    let to = cycle[(edge_index + 1) % cycle.len()];
188    graph
189        .find_import_span_start(from, to)
190        .map(|span_start| byte_offset_to_line_col(line_offsets_map, from, span_start))
191}
192
193fn is_circular_dependency_suppressed(
194    graph: &ModuleGraph,
195    line_offsets_map: &LineOffsetsMap<'_>,
196    suppressions: &crate::suppress::SuppressionContext<'_>,
197    cycle: &[FileId],
198) -> bool {
199    if cycle
200        .iter()
201        .any(|&id| suppressions.is_file_suppressed(id, IssueKind::CircularDependency))
202    {
203        return true;
204    }
205
206    let mut line_suppressed = false;
207    for edge_index in 0..cycle.len() {
208        let from = cycle[edge_index];
209        if let Some((line, _)) = cycle_edge_line_col(graph, line_offsets_map, cycle, edge_index)
210            && suppressions.is_suppressed(from, line, IssueKind::CircularDependency)
211        {
212            line_suppressed = true;
213        }
214    }
215    line_suppressed
216}
217
218/// Read source content from disk, returning empty string on failure.
219/// Only used for LSP Code Lens reference resolution where the referencing
220/// file may not be in the line offsets map.
221fn read_source(path: &std::path::Path) -> String {
222    std::fs::read_to_string(path).unwrap_or_default()
223}
224
225/// Check whether any two files in a cycle belong to different workspace packages.
226/// Uses longest-prefix-match to assign each file to a workspace root.
227/// Files outside all workspace roots (e.g., root-level shared code) are ignored,
228/// only cycles between two distinct named workspaces are flagged.
229fn is_cross_package_cycle(
230    files: &[std::path::PathBuf],
231    workspaces: &[fallow_config::WorkspaceInfo],
232) -> bool {
233    let find_workspace = |path: &std::path::Path| -> Option<&std::path::Path> {
234        workspaces
235            .iter()
236            .map(|w| w.root.as_path())
237            .filter(|root| path.starts_with(root))
238            .max_by_key(|root| root.components().count())
239    };
240
241    let mut seen_workspace: Option<&std::path::Path> = None;
242    for file in files {
243        if let Some(ws) = find_workspace(file) {
244            match &seen_workspace {
245                None => seen_workspace = Some(ws),
246                Some(prev) if *prev != ws => return true,
247                _ => {}
248            }
249        }
250    }
251    false
252}
253
254fn public_workspace_roots<'a>(
255    public_packages: &[String],
256    workspaces: &'a [fallow_config::WorkspaceInfo],
257) -> Vec<&'a std::path::Path> {
258    if public_packages.is_empty() || workspaces.is_empty() {
259        return Vec::new();
260    }
261
262    workspaces
263        .iter()
264        .filter(|ws| {
265            public_packages.iter().any(|pattern| {
266                ws.name == *pattern
267                    || globset::Glob::new(pattern)
268                        .ok()
269                        .is_some_and(|g| g.compile_matcher().is_match(&ws.name))
270            })
271        })
272        .map(|ws| ws.root.as_path())
273        .collect()
274}
275
276fn graph_path_to_file_id(graph: &ModuleGraph) -> FxHashMap<std::path::PathBuf, FileId> {
277    let mut path_to_file_id = FxHashMap::default();
278    for module in &graph.modules {
279        path_to_file_id.insert(module.path.clone(), module.file_id);
280        if let Ok(canonical) = dunce::canonicalize(&module.path) {
281            path_to_file_id.insert(canonical, module.file_id);
282        }
283    }
284    path_to_file_id
285}
286
287fn add_package_public_api_entry_points(
288    public_api_entry_points: &mut FxHashSet<FileId>,
289    path_to_file_id: &FxHashMap<std::path::PathBuf, FileId>,
290    package_root: &std::path::Path,
291    package_json: &PackageJson,
292    canonical_project_root: &std::path::Path,
293) {
294    if package_json.private.unwrap_or(false) {
295        return;
296    }
297
298    for entry in package_json.entry_points() {
299        let Some(entry_point) = crate::discover::resolve_entry_path(
300            package_root,
301            &entry,
302            canonical_project_root,
303            crate::discover::EntryPointSource::PackageJsonExports,
304        ) else {
305            continue;
306        };
307
308        if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
309            dunce::canonicalize(&entry_point.path)
310                .ok()
311                .and_then(|canonical| path_to_file_id.get(&canonical).copied())
312        }) {
313            public_api_entry_points.insert(file_id);
314        }
315    }
316}
317
318fn is_source_index_under_package(path: &std::path::Path, package_root: &std::path::Path) -> bool {
319    let Ok(relative) = path.strip_prefix(package_root) else {
320        return false;
321    };
322
323    if !matches!(
324        relative.components().next(),
325        Some(std::path::Component::Normal(segment)) if segment == "src"
326    ) {
327        return false;
328    }
329
330    path.file_stem()
331        .and_then(|stem| stem.to_str())
332        .is_some_and(|stem| stem == "index")
333}
334
335fn add_exportless_package_source_indexes(
336    public_api_entry_points: &mut FxHashSet<FileId>,
337    graph: &ModuleGraph,
338    package_root: &std::path::Path,
339    package_json: &PackageJson,
340) {
341    if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
342        return;
343    }
344
345    let mut roots = vec![package_root.to_path_buf()];
346    if let Ok(canonical) = dunce::canonicalize(package_root) {
347        roots.push(canonical);
348    }
349
350    for module in &graph.modules {
351        if roots
352            .iter()
353            .any(|root| is_source_index_under_package(&module.path, root))
354        {
355            public_api_entry_points.insert(module.file_id);
356        }
357    }
358}
359
360/// Compute the exports-aware public-API entry-point set: the `package.json`
361/// `exports`-mapped modules (non-private packages) plus the no-`exports`
362/// source-index fallback. This encodes rule R4 (the `exports`-mapped copy is
363/// public; a no-`exports` copy is internal). Exposed for the review brief,
364/// which feeds it into [`ModuleGraph::public_export_keys`] to compute the
365/// exports-aware public-API surface delta.
366pub fn public_api_package_entry_points(
367    graph: &ModuleGraph,
368    config: &ResolvedConfig,
369    root_pkg: Option<&PackageJson>,
370    workspaces: &[fallow_config::WorkspaceInfo],
371) -> FxHashSet<FileId> {
372    let mut public_api_entry_points = FxHashSet::default();
373    let path_to_file_id = graph_path_to_file_id(graph);
374    let canonical_project_root =
375        dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
376
377    add_root_public_api_entry_points(
378        &mut public_api_entry_points,
379        graph,
380        &path_to_file_id,
381        config,
382        root_pkg,
383        &canonical_project_root,
384    );
385    add_workspace_public_api_entry_points(
386        &mut public_api_entry_points,
387        graph,
388        &path_to_file_id,
389        workspaces,
390        &canonical_project_root,
391    );
392
393    public_api_entry_points
394}
395
396fn add_root_public_api_entry_points(
397    public_api_entry_points: &mut FxHashSet<FileId>,
398    graph: &ModuleGraph,
399    path_to_file_id: &FxHashMap<std::path::PathBuf, FileId>,
400    config: &ResolvedConfig,
401    root_pkg: Option<&PackageJson>,
402    canonical_project_root: &std::path::Path,
403) {
404    if let Some(pkg) = root_pkg {
405        add_package_public_api_entry_points(
406            public_api_entry_points,
407            path_to_file_id,
408            &config.root,
409            pkg,
410            canonical_project_root,
411        );
412        add_exportless_package_source_indexes(public_api_entry_points, graph, &config.root, pkg);
413    }
414}
415
416fn add_workspace_public_api_entry_points(
417    public_api_entry_points: &mut FxHashSet<FileId>,
418    graph: &ModuleGraph,
419    path_to_file_id: &FxHashMap<std::path::PathBuf, FileId>,
420    workspaces: &[fallow_config::WorkspaceInfo],
421    canonical_project_root: &std::path::Path,
422) {
423    for workspace in workspaces {
424        let Ok(pkg) = PackageJson::load(&workspace.root.join("package.json")) else {
425            continue;
426        };
427        add_package_public_api_entry_points(
428            public_api_entry_points,
429            path_to_file_id,
430            &workspace.root,
431            &pkg,
432            canonical_project_root,
433        );
434        add_exportless_package_source_indexes(
435            public_api_entry_points,
436            graph,
437            &workspace.root,
438            &pkg,
439        );
440    }
441}
442
443fn find_circular_dependencies(
444    graph: &ModuleGraph,
445    line_offsets_map: &LineOffsetsMap<'_>,
446    suppressions: &crate::suppress::SuppressionContext<'_>,
447    workspaces: &[fallow_config::WorkspaceInfo],
448) -> Vec<CircularDependency> {
449    let cycles = graph.find_cycles();
450    let mut dependencies: Vec<CircularDependency> = cycles
451        .into_iter()
452        .filter_map(|cycle| {
453            if is_circular_dependency_suppressed(graph, line_offsets_map, suppressions, &cycle) {
454                return None;
455            }
456            Some(circular_dependency_from_cycle(
457                graph,
458                line_offsets_map,
459                &cycle,
460            ))
461        })
462        .collect();
463
464    if !workspaces.is_empty() {
465        for dep in &mut dependencies {
466            dep.is_cross_package = is_cross_package_cycle(&dep.files, workspaces);
467        }
468    }
469
470    dependencies
471}
472
473fn circular_dependency_from_cycle(
474    graph: &ModuleGraph,
475    line_offsets_map: &LineOffsetsMap<'_>,
476    cycle: &[FileId],
477) -> CircularDependency {
478    // One anchor per hop in cycle order: `edges[i]` is the import in
479    // `cycle[i]` pointing to `cycle[i + 1]`. Always populated for every
480    // hop (fallback `(1, 0)` if the span is somehow missing) so
481    // `edges.len() == files.len()` regardless of URL-resolvability on
482    // the consumer side. The LSP renders one squiggly per edge.
483    let edges: Vec<CircularDependencyEdge> = (0..cycle.len())
484        .map(|edge_index| {
485            let from = cycle[edge_index];
486            let (line, col) =
487                cycle_edge_line_col(graph, line_offsets_map, cycle, edge_index).unwrap_or((1, 0));
488            CircularDependencyEdge {
489                path: graph.modules[from.0 as usize].path.clone(),
490                line,
491                col,
492            }
493        })
494        .collect();
495
496    let files: Vec<std::path::PathBuf> = edges.iter().map(|edge| edge.path.clone()).collect();
497    let length = files.len();
498    // Top-level `line`/`col` remain the first hop's anchor for backward
499    // compatibility with consumers that predate `edges`.
500    let (line, col) = edges.first().map_or((1, 0), |edge| (edge.line, edge.col));
501    CircularDependency {
502        files,
503        length,
504        line,
505        col,
506        edges,
507        is_cross_package: false,
508    }
509}
510
511/// Thin wrapper around [`find_circular_dependencies`] that gates on
512/// `Severity::Off` and wraps the bare results in typed envelopes.
513/// Extracted from the rayon-join tree to keep nesting under the clippy
514/// `excessive_nesting` threshold (7).
515fn run_circular_dep_detector(
516    graph: &ModuleGraph,
517    config: &ResolvedConfig,
518    line_offsets_by_file: &LineOffsetsMap<'_>,
519    suppressions: &crate::suppress::SuppressionContext<'_>,
520    workspaces: &[fallow_config::WorkspaceInfo],
521) -> Vec<CircularDependencyFinding> {
522    if config.rules.circular_dependencies == Severity::Off {
523        return Vec::new();
524    }
525    find_circular_dependencies(graph, line_offsets_by_file, suppressions, workspaces)
526        .into_iter()
527        .map(CircularDependencyFinding::with_actions)
528        .collect()
529}
530
531/// Thin wrapper around
532/// [`boundary_coverage::find_boundary_coverage_violations`] that gates on the
533/// shared `boundary-violation` severity. Extracted alongside
534/// [`run_circular_dep_detector`].
535fn run_boundary_coverage_detector(
536    graph: &ModuleGraph,
537    config: &ResolvedConfig,
538    suppressions: &crate::suppress::SuppressionContext<'_>,
539) -> Vec<BoundaryCoverageViolationFinding> {
540    if config.rules.boundary_violation == Severity::Off {
541        return Vec::new();
542    }
543    boundary_coverage::find_boundary_coverage_violations(graph, config, suppressions)
544        .into_iter()
545        .map(BoundaryCoverageViolationFinding::with_actions)
546        .collect()
547}
548
549/// Thin wrapper around [`boundary_calls::find_boundary_call_violations`] that
550/// gates on the shared `boundary-violation` severity. Extracted alongside
551/// [`run_circular_dep_detector`].
552fn run_boundary_call_detector(
553    graph: &ModuleGraph,
554    modules: &[ModuleInfo],
555    config: &ResolvedConfig,
556    suppressions: &crate::suppress::SuppressionContext<'_>,
557    line_offsets_by_file: &LineOffsetsMap<'_>,
558) -> Vec<BoundaryCallViolationFinding> {
559    if config.rules.boundary_violation == Severity::Off {
560        return Vec::new();
561    }
562    boundary_calls::find_boundary_call_violations(
563        graph,
564        modules,
565        config,
566        suppressions,
567        line_offsets_by_file,
568    )
569    .into_iter()
570    .map(BoundaryCallViolationFinding::with_actions)
571    .collect()
572}
573
574/// Thin wrapper around [`policy::find_policy_violations`] that gates on the
575/// `policy-violation` master severity (a kill switch: per-rule severity
576/// cannot resurrect it) and on at least one configured rule pack. Extracted
577/// alongside [`run_circular_dep_detector`].
578fn run_policy_detector(
579    graph: &ModuleGraph,
580    modules: &[ModuleInfo],
581    config: &ResolvedConfig,
582    declared_deps: &FxHashSet<String>,
583    suppressions: &crate::suppress::SuppressionContext<'_>,
584    line_offsets_by_file: &LineOffsetsMap<'_>,
585) -> Vec<PolicyViolationFinding> {
586    if config.rules.policy_violation == Severity::Off || config.rule_packs.is_empty() {
587        return Vec::new();
588    }
589    policy::find_policy_violations(
590        graph,
591        modules,
592        config,
593        declared_deps,
594        suppressions,
595        line_offsets_by_file,
596    )
597    .into_iter()
598    .map(PolicyViolationFinding::with_actions)
599    .collect()
600}
601
602/// Run the boundary-coverage, boundary-call, and rule-pack policy detectors
603/// in parallel. Extracted so the main `find_dead_code_full` join tree stays
604/// within the nesting budget.
605fn run_boundary_aux_detectors(
606    graph: &ModuleGraph,
607    modules: &[ModuleInfo],
608    config: &ResolvedConfig,
609    declared_deps: &FxHashSet<String>,
610    suppressions: &crate::suppress::SuppressionContext<'_>,
611    line_offsets_by_file: &LineOffsetsMap<'_>,
612) -> (
613    Vec<BoundaryCoverageViolationFinding>,
614    (
615        Vec<BoundaryCallViolationFinding>,
616        Vec<PolicyViolationFinding>,
617    ),
618) {
619    rayon::join(
620        || run_boundary_coverage_detector(graph, config, suppressions),
621        || {
622            rayon::join(
623                || {
624                    run_boundary_call_detector(
625                        graph,
626                        modules,
627                        config,
628                        suppressions,
629                        line_offsets_by_file,
630                    )
631                },
632                || {
633                    run_policy_detector(
634                        graph,
635                        modules,
636                        config,
637                        declared_deps,
638                        suppressions,
639                        line_offsets_by_file,
640                    )
641                },
642            )
643        },
644    )
645}
646
647/// Thin wrapper around [`re_export_cycles::find_re_export_cycles`] that gates
648/// on `Severity::Off`. Extracted alongside [`run_circular_dep_detector`].
649fn run_re_export_cycle_detector(
650    graph: &ModuleGraph,
651    config: &ResolvedConfig,
652    suppressions: &crate::suppress::SuppressionContext<'_>,
653) -> Vec<ReExportCycleFinding> {
654    if config.rules.re_export_cycle == Severity::Off {
655        return Vec::new();
656    }
657    find_re_export_cycles(graph, suppressions)
658}
659
660/// Collect export usage counts for Code Lens (LSP feature). Skipped in CLI
661/// mode since the field is `#[serde(skip)]` in all output formats.
662fn run_export_usages_collector(
663    graph: &ModuleGraph,
664    line_offsets_by_file: &LineOffsetsMap<'_>,
665    collect_usages: bool,
666) -> Vec<crate::results::ExportUsage> {
667    if collect_usages {
668        collect_export_usages(graph, line_offsets_by_file)
669    } else {
670        Vec::new()
671    }
672}
673
674/// Collect every package name declared across the root `package.json` and each
675/// workspace `package.json`. This is the dependency universe the plugin system
676/// activates on, reused by the framework-scoped security catalogue rows (#861) to
677/// gate a row on the active framework. Missing or malformed manifests contribute
678/// nothing (a framework row simply stays inert), matching the conservative
679/// false-negatives-over-false-positives posture.
680fn collect_declared_dependency_names(
681    config: &ResolvedConfig,
682    root_pkg: Option<&PackageJson>,
683    workspaces: &[fallow_config::WorkspaceInfo],
684) -> FxHashSet<String> {
685    let mut deps: FxHashSet<String> = FxHashSet::default();
686    if let Some(pkg) = root_pkg {
687        deps.extend(pkg.all_dependency_names());
688    }
689    for ws in workspaces {
690        if ws.root == config.root {
691            continue; // already covered by root_pkg
692        }
693        if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
694            deps.extend(pkg.all_dependency_names());
695        }
696    }
697    deps
698}
699
700struct DeadCodeRunContext<'a> {
701    suppressions: SuppressionContext<'a>,
702    line_offsets_by_file: LineOffsetsMap<'a>,
703    pkg: Option<PackageJson>,
704    public_api_entry_points: FxHashSet<FileId>,
705    declared_deps: FxHashSet<String>,
706}
707
708fn build_dead_code_run_context<'a>(
709    graph: &'a ModuleGraph,
710    config: &ResolvedConfig,
711    workspaces: &[fallow_config::WorkspaceInfo],
712    modules: &'a [ModuleInfo],
713) -> DeadCodeRunContext<'a> {
714    let suppressions = SuppressionContext::new(modules);
715    let line_offsets_by_file: LineOffsetsMap<'a> = modules
716        .iter()
717        .filter(|m| !m.line_offsets.is_empty())
718        .map(|m| (m.file_id, m.line_offsets.as_slice()))
719        .collect();
720
721    let pkg_path = config.root.join("package.json");
722    let pkg = PackageJson::load(&pkg_path).ok();
723    let public_api_entry_points =
724        public_api_package_entry_points(graph, config, pkg.as_ref(), workspaces);
725    let declared_deps = collect_declared_dependency_names(config, pkg.as_ref(), workspaces);
726
727    DeadCodeRunContext {
728        suppressions,
729        line_offsets_by_file,
730        pkg,
731        public_api_entry_points,
732        declared_deps,
733    }
734}
735
736/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
737#[deprecated(
738    since = "2.76.0",
739    note = "fallow_core is internal; use fallow_api::detect_dead_code instead. NOTE: replacement returns serde_json::Value, not typed AnalysisResults. See docs/fallow-core-migration.md and ADR-008."
740)]
741#[expect(
742    clippy::too_many_arguments,
743    reason = "frozen deprecated public API (ADR-008); signature must not change"
744)]
745pub fn find_dead_code_full(
746    graph: &ModuleGraph,
747    config: &ResolvedConfig,
748    resolved_modules: &[ResolvedModule],
749    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
750    workspaces: &[fallow_config::WorkspaceInfo],
751    modules: &[ModuleInfo],
752    collect_usages: bool,
753) -> AnalysisResults {
754    let _span = tracing::info_span!("find_dead_code").entered();
755
756    let run_context = build_dead_code_run_context(graph, config, workspaces, modules);
757
758    let mut results = run_setup_and_detect(&SetupAndDetectInput {
759        graph,
760        config,
761        resolved_modules,
762        plugin_result,
763        workspaces,
764        modules,
765        suppressions: &run_context.suppressions,
766        line_offsets_by_file: &run_context.line_offsets_by_file,
767        pkg: run_context.pkg.as_ref(),
768        public_api_entry_points: &run_context.public_api_entry_points,
769        declared_deps: &run_context.declared_deps,
770        collect_usages,
771    });
772
773    populate_post_detection_findings(&mut PostDetectionInput {
774        graph,
775        modules,
776        resolved_modules,
777        config,
778        workspaces,
779        declared_deps: &run_context.declared_deps,
780        public_api_entry_points: &run_context.public_api_entry_points,
781        suppressions: &run_context.suppressions,
782        line_offsets_by_file: &run_context.line_offsets_by_file,
783        collect_usages,
784        results: &mut results,
785    });
786
787    results.sort();
788
789    results
790}
791
792/// Inputs to the dead-code setup-and-detect phase: the pre-run-shared context
793/// plus the raw plugin result the iconify augmentation may extend.
794struct SetupAndDetectInput<'a, 'm> {
795    graph: &'a ModuleGraph,
796    config: &'a ResolvedConfig,
797    resolved_modules: &'a [ResolvedModule],
798    plugin_result: Option<&'a crate::plugins::AggregatedPluginResult>,
799    workspaces: &'a [fallow_config::WorkspaceInfo],
800    modules: &'a [ModuleInfo],
801    suppressions: &'a SuppressionContext<'m>,
802    line_offsets_by_file: &'a LineOffsetsMap<'m>,
803    pkg: Option<&'a PackageJson>,
804    public_api_entry_points: &'a FxHashSet<FileId>,
805    declared_deps: &'a FxHashSet<String>,
806    collect_usages: bool,
807}
808
809/// Build the iconify-augmented plugin result, derive plugin-backed slices and
810/// the user class-member set, then run the parallel dead-code detectors.
811/// Extracted from `find_dead_code_full` to keep that orchestrator's body as
812/// setup -> detect -> populate.
813fn run_setup_and_detect(input: &SetupAndDetectInput<'_, '_>) -> AnalysisResults {
814    let iconify_referenced =
815        iconify::collect_iconify_referenced_deps(input.modules, input.pkg, input.workspaces);
816    let augmented_plugin_result;
817    let plugin_result = if iconify_referenced.is_empty() {
818        input.plugin_result
819    } else {
820        let mut owned = input.plugin_result.cloned().unwrap_or_default();
821        owned.referenced_dependencies.extend(iconify_referenced);
822        augmented_plugin_result = owned;
823        Some(&augmented_plugin_result)
824    };
825
826    let mut user_class_members = input.config.used_class_members.clone();
827    if let Some(plugin_result) = plugin_result {
828        user_class_members.extend(plugin_result.used_class_members.iter().cloned());
829    }
830
831    let (virtual_prefixes, generated_patterns, generated_type_prefixes) =
832        derive_plugin_string_slices(plugin_result);
833
834    run_parallel_dead_code_detectors(DeadCodeDetectorInput {
835        graph: input.graph,
836        config: input.config,
837        resolved_modules: input.resolved_modules,
838        workspaces: input.workspaces,
839        modules: input.modules,
840        suppressions: input.suppressions,
841        line_offsets_by_file: input.line_offsets_by_file,
842        plugin_result,
843        pkg: input.pkg,
844        user_class_members: &user_class_members,
845        public_api_entry_points: input.public_api_entry_points,
846        virtual_prefixes: &virtual_prefixes,
847        generated_patterns: &generated_patterns,
848        generated_type_prefixes: &generated_type_prefixes,
849        declared_deps: input.declared_deps,
850        collect_usages: input.collect_usages,
851    })
852}
853
854/// Derive the borrowed plugin string slices (virtual module prefixes, generated
855/// import patterns, generated type-import prefixes) consumed by the detectors.
856fn derive_plugin_string_slices(
857    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
858) -> (Vec<&str>, Vec<&str>, Vec<&str>) {
859    let virtual_prefixes = plugin_result
860        .map(|pr| {
861            pr.virtual_module_prefixes
862                .iter()
863                .map(String::as_str)
864                .collect()
865        })
866        .unwrap_or_default();
867    let generated_patterns = plugin_result
868        .map(|pr| {
869            pr.generated_import_patterns
870                .iter()
871                .map(String::as_str)
872                .collect()
873        })
874        .unwrap_or_default();
875    let generated_type_prefixes = plugin_result
876        .map(|pr| {
877            pr.generated_type_import_prefixes
878                .iter()
879                .map(String::as_str)
880                .collect()
881        })
882        .unwrap_or_default();
883    (
884        virtual_prefixes,
885        generated_patterns,
886        generated_type_prefixes,
887    )
888}
889
890/// Shared context for the post-detector populate sequence in
891/// `find_dead_code_full`.
892struct PostDetectionInput<'a, 'm> {
893    graph: &'a ModuleGraph,
894    modules: &'a [ModuleInfo],
895    resolved_modules: &'a [ResolvedModule],
896    config: &'a ResolvedConfig,
897    workspaces: &'a [fallow_config::WorkspaceInfo],
898    declared_deps: &'a FxHashSet<String>,
899    public_api_entry_points: &'a FxHashSet<FileId>,
900    suppressions: &'a SuppressionContext<'m>,
901    line_offsets_by_file: &'a LineOffsetsMap<'m>,
902    /// Whether the editor/LSP usages path is active; gates in-process-only
903    /// intel (`react_component_intel`) off the bare `fallow` / `audit` hot path.
904    collect_usages: bool,
905    results: &'a mut AnalysisResults,
906}
907
908/// Run the post-detector populate/reclassify phases: server-action
909/// reclassification, security, catalog/override, framework-convention findings,
910/// and stale-suppression accounting. Extracted from `find_dead_code_full` so
911/// that orchestrator reads as setup -> detect -> populate.
912fn populate_post_detection_findings(input: &mut PostDetectionInput<'_, '_>) {
913    filter_public_workspace_results(input.config, input.workspaces, input.results);
914
915    // Reclassify the server-action subset of unused exports BEFORE stale
916    // detection so a `// fallow-ignore-next-line unused-server-action` marker is
917    // recorded as consumed. Gate-off keeps the findings as plain unused-exports.
918    if input.config.rules.unused_server_actions != Severity::Off {
919        reclassify_unused_server_actions(
920            input.graph,
921            input.modules,
922            input.declared_deps,
923            input.suppressions,
924            input.results,
925        );
926    }
927
928    populate_configured_security_findings(input);
929    populate_package_and_framework_findings(input);
930    populate_stale_suppression_findings(input);
931}
932
933fn populate_configured_security_findings(input: &mut PostDetectionInput<'_, '_>) {
934    let request_receivers = input
935        .config
936        .security
937        .request_receivers
938        .iter()
939        .cloned()
940        .collect::<FxHashSet<_>>();
941
942    populate_security_findings(
943        &SecurityDetectionContext {
944            graph: input.graph,
945            modules: input.modules,
946            config: input.config,
947            suppressions: input.suppressions,
948            line_offsets_by_file: input.line_offsets_by_file,
949            declared_deps: input.declared_deps,
950            request_receivers: &request_receivers,
951        },
952        input.results,
953    );
954}
955
956fn populate_package_and_framework_findings(input: &mut PostDetectionInput<'_, '_>) {
957    // Framework-convention detectors run BEFORE stale-suppression detection so
958    // any inline suppression they consume (e.g. a `// fallow-ignore-next-line
959    // unused-component-prop` honored by the prop/emit/component detectors) is
960    // recorded consumed and not falsely reported stale. These detectors gate on
961    // their own rule severity and dep presence, so they are no-ops when inactive.
962    populate_pnpm_catalog_findings(input.config, input.workspaces, input.results);
963    populate_pnpm_override_findings(input.config, input.workspaces, input.results);
964    populate_framework_specific_findings(&mut FrameworkSpecificFindingsInput {
965        graph: input.graph,
966        modules: input.modules,
967        resolved_modules: input.resolved_modules,
968        config: input.config,
969        workspaces: input.workspaces,
970        declared_deps: input.declared_deps,
971        public_api_entry_points: input.public_api_entry_points,
972        suppressions: input.suppressions,
973        line_offsets_by_file: input.line_offsets_by_file,
974        collect_usages: input.collect_usages,
975        results: input.results,
976    });
977}
978
979/// Append stale-suppression and missing-reason findings, then record the
980/// suppression accounting metadata onto the results.
981fn populate_stale_suppression_findings(input: &mut PostDetectionInput<'_, '_>) {
982    if input.config.rules.stale_suppressions != Severity::Off {
983        input
984            .results
985            .stale_suppressions
986            .extend(input.suppressions.find_stale(input.graph, input.config));
987    }
988    if input.config.rules.require_suppression_reason != Severity::Off {
989        input
990            .results
991            .stale_suppressions
992            .extend(input.suppressions.find_missing_reasons(input.graph));
993    }
994    input.results.suppression_count = input.suppressions.used_count();
995    input.results.active_suppressions = input.suppressions.all_suppressions(input.graph);
996}
997
998/// Run the framework-convention detectors that share the resolved-graph and
999/// dep-gate context: Next.js RSC directives, Vue/Svelte DI and components, and
1000/// the App Router route tree. Extracted from `find_dead_code_full` to keep that
1001/// orchestrator under the unit-size ceiling; each callee is individually
1002/// rule-gated.
1003struct FrameworkSpecificFindingsInput<'a> {
1004    graph: &'a ModuleGraph,
1005    modules: &'a [ModuleInfo],
1006    resolved_modules: &'a [ResolvedModule],
1007    config: &'a ResolvedConfig,
1008    workspaces: &'a [fallow_config::WorkspaceInfo],
1009    declared_deps: &'a FxHashSet<String>,
1010    public_api_entry_points: &'a FxHashSet<FileId>,
1011    suppressions: &'a SuppressionContext<'a>,
1012    line_offsets_by_file: &'a LineOffsetsMap<'a>,
1013    /// Mirror of `PostDetectionInput::collect_usages`; gates the LSP-only
1014    /// `react_component_intel` computation.
1015    collect_usages: bool,
1016    results: &'a mut AnalysisResults,
1017}
1018
1019fn populate_framework_specific_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1020    populate_client_boundary_findings(input);
1021    populate_component_contract_findings(input);
1022    populate_react_health_findings(input);
1023    populate_nextjs_findings(input);
1024}
1025
1026fn populate_client_boundary_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1027    populate_invalid_client_export_findings(input);
1028    populate_mixed_client_server_barrel_findings(input);
1029    populate_misplaced_directive_findings(input);
1030}
1031
1032fn populate_component_contract_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1033    populate_unprovided_inject_findings(input);
1034    populate_unrendered_component_findings(input);
1035    populate_unused_component_prop_findings(input);
1036    populate_unused_component_emit_findings(
1037        input.graph,
1038        input.modules,
1039        input.config,
1040        input.declared_deps,
1041        input.line_offsets_by_file,
1042        input.results,
1043    );
1044    populate_unused_component_input_findings(
1045        input.graph,
1046        input.modules,
1047        input.config,
1048        input.declared_deps,
1049        input.line_offsets_by_file,
1050        input.results,
1051    );
1052    populate_unused_component_output_findings(
1053        input.graph,
1054        input.modules,
1055        input.config,
1056        input.declared_deps,
1057        input.line_offsets_by_file,
1058        input.results,
1059    );
1060    populate_unused_svelte_event_findings(
1061        input.graph,
1062        input.modules,
1063        input.config,
1064        input.declared_deps,
1065        input.line_offsets_by_file,
1066        input.results,
1067    );
1068    populate_unused_load_data_key_findings(input);
1069}
1070
1071fn populate_react_health_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1072    populate_prop_drilling_findings(input);
1073    populate_thin_wrapper_findings(input);
1074    populate_render_fan_in(input);
1075    populate_react_component_intel(input);
1076    populate_duplicate_prop_shape_findings(input);
1077}
1078
1079fn populate_nextjs_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1080    populate_nextjs_route_tree_findings(
1081        input.graph,
1082        input.config,
1083        input.workspaces,
1084        input.declared_deps,
1085        input.suppressions,
1086        input.results,
1087    );
1088}
1089
1090/// Populate the descriptive component render fan-in metric (the component-graph
1091/// analogue of module fan-in). UNLIKE the prop-drilling / thin-wrapper detectors
1092/// this is NOT rule-gated: it is a descriptive blast-radius signal that runs
1093/// whenever React is declared (the dep gate lives inside
1094/// [`compute_render_fan_in`]). The field is `#[serde(skip)]` on
1095/// [`AnalysisResults`], so it never serializes under bare `fallow` / `audit`; it
1096/// is read in-process by the health vital-signs computation only.
1097fn populate_render_fan_in(input: &mut FrameworkSpecificFindingsInput<'_>) {
1098    input.results.render_fan_in = compute_render_fan_in(
1099        input.graph,
1100        input.modules,
1101        input.resolved_modules,
1102        input.declared_deps,
1103        &input.config.root,
1104    );
1105}
1106
1107/// Populate the descriptive per-component React intelligence carrier (render
1108/// sites, props, hooks). Like [`populate_render_fan_in`] this is NOT rule-gated:
1109/// it is a descriptive ambient-editor signal computed whenever React is declared
1110/// (the dep gate lives inside [`compute_react_component_intel`]). The field is
1111/// `#[serde(skip)]` on [`AnalysisResults`], so it never serializes under bare
1112/// `fallow` / `audit`; it is read in-process by the LSP code-lens / hover layer
1113/// only. Gated on `collect_usages` (the editor/LSP path) so bare `fallow` /
1114/// `audit` (the CI hot path) never pay for the render aggregation + prop-drilling
1115/// chain traversal that nothing on those paths reads.
1116fn populate_react_component_intel(input: &mut FrameworkSpecificFindingsInput<'_>) {
1117    if !input.collect_usages {
1118        return;
1119    }
1120    input.results.react_component_intel = compute_react_component_intel(
1121        input.graph,
1122        input.modules,
1123        input.resolved_modules,
1124        input.declared_deps,
1125        &input.config.root,
1126        input.line_offsets_by_file,
1127    );
1128}
1129
1130/// Populate `unused_load_data_keys` when the rule is enabled. Gated on the
1131/// project declaring `@sveltejs/kit` inside the detector (see
1132/// [`find_unused_load_data_keys`]). Runs as a sequential populate because it
1133/// needs the run's `declared_deps` for the dep gate.
1134fn populate_unused_load_data_key_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1135    if input.config.rules.unused_load_data_keys == Severity::Off {
1136        return;
1137    }
1138    let result = find_unused_load_data_keys(
1139        input.graph,
1140        input.modules,
1141        input.declared_deps,
1142        input.suppressions,
1143        input.line_offsets_by_file,
1144        &input.config.root,
1145    );
1146    if result.global_abstain {
1147        input.results.unused_load_data_keys_global_abstain = true;
1148        tracing::debug!(
1149            "unused-load-data-key: abstained project-wide (a whole-object use of \
1150             page.data / $page.data was seen; any key could be read reflectively)"
1151        );
1152    }
1153    input.results.unused_load_data_keys = result
1154        .findings
1155        .into_iter()
1156        .map(UnusedLoadDataKeyFinding::with_actions)
1157        .collect();
1158}
1159
1160/// Populate `invalid_client_exports` when the rule is enabled. Gated on the
1161/// project declaring `next` inside the detector (see
1162/// [`find_invalid_client_exports`]).
1163fn populate_invalid_client_export_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1164    if input.config.rules.invalid_client_export == Severity::Off {
1165        return;
1166    }
1167    input.results.invalid_client_exports = find_invalid_client_exports(
1168        input.graph,
1169        input.modules,
1170        input.declared_deps,
1171        input.suppressions,
1172        input.line_offsets_by_file,
1173    )
1174    .into_iter()
1175    .map(InvalidClientExportFinding::with_actions)
1176    .collect();
1177}
1178
1179/// Populate `mixed_client_server_barrels` when the rule is enabled. Gated on the
1180/// project declaring `next` inside the detector (see
1181/// [`find_mixed_client_server_barrels`]).
1182fn populate_mixed_client_server_barrel_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1183    if input.config.rules.mixed_client_server_barrel == Severity::Off {
1184        return;
1185    }
1186    input.results.mixed_client_server_barrels = find_mixed_client_server_barrels(
1187        input.graph,
1188        input.modules,
1189        input.resolved_modules,
1190        input.declared_deps,
1191        input.suppressions,
1192        input.line_offsets_by_file,
1193    )
1194    .into_iter()
1195    .map(MixedClientServerBarrelFinding::with_actions)
1196    .collect();
1197}
1198
1199/// Populate `misplaced_directives` when the rule is enabled. Gated on the
1200/// project declaring `next` inside the detector (see
1201/// [`find_misplaced_directives`]).
1202fn populate_misplaced_directive_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1203    if input.config.rules.misplaced_directive == Severity::Off {
1204        return;
1205    }
1206    input.results.misplaced_directives = find_misplaced_directives(
1207        input.graph,
1208        input.modules,
1209        input.declared_deps,
1210        input.suppressions,
1211        input.line_offsets_by_file,
1212    )
1213    .into_iter()
1214    .map(MisplacedDirectiveFinding::with_actions)
1215    .collect();
1216}
1217
1218/// Populate `unprovided_injects` when the rule is enabled. Gated on the project
1219/// declaring `vue` / `@vue/runtime-core` / `svelte` inside the detector (see
1220/// [`find_unprovided_injects`]).
1221fn populate_unprovided_inject_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1222    if input.config.rules.unprovided_injects == Severity::Off {
1223        return;
1224    }
1225    input.results.unprovided_injects = find_unprovided_injects(UnprovidedInjectInput {
1226        graph: input.graph,
1227        resolved_modules: input.resolved_modules,
1228        modules: input.modules,
1229        declared_deps: input.declared_deps,
1230        public_api_entry_points: input.public_api_entry_points,
1231        suppressions: input.suppressions,
1232        line_offsets_by_file: input.line_offsets_by_file,
1233    })
1234    .into_iter()
1235    .map(UnprovidedInjectFinding::with_actions)
1236    .collect();
1237}
1238
1239/// Populate `unrendered_components` when the rule is enabled. Gated on the
1240/// project declaring `vue` / `svelte` inside the detector (see
1241/// [`find_unrendered_components`]).
1242fn populate_unrendered_component_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1243    if input.config.rules.unrendered_components == Severity::Off {
1244        return;
1245    }
1246    input.results.unrendered_components = find_unrendered_components(
1247        input.graph,
1248        input.resolved_modules,
1249        input.modules,
1250        input.declared_deps,
1251        input.public_api_entry_points,
1252        input.suppressions,
1253    )
1254    .into_iter()
1255    .map(UnrenderedComponentFinding::with_actions)
1256    .collect();
1257    // Angular arm: a separate detection arm (selector-based) producing the SAME
1258    // finding kind / result type with `framework: "angular"`, appended to the
1259    // same vector. Gated on `@angular/core` inside the detector. Mirrors how the
1260    // Vue Options-API arm extends the existing rule (no new IssueKind).
1261    input.results.unrendered_components.extend(
1262        find_unrendered_angular_components(
1263            input.graph,
1264            input.modules,
1265            input.declared_deps,
1266            input.public_api_entry_points,
1267            input.line_offsets_by_file,
1268            input.suppressions,
1269        )
1270        .into_iter()
1271        .map(UnrenderedComponentFinding::with_actions),
1272    );
1273    // Lit arm: a registered custom element (`@customElement` /
1274    // `customElements.define`) rendered as a tag in no `html` template. SAME
1275    // finding kind / result type with `framework: "lit"`, gated on a Lit
1276    // dependency inside the detector. No new IssueKind.
1277    input.results.unrendered_components.extend(
1278        find_unrendered_lit_elements(&LitUnrenderedInput {
1279            graph: input.graph,
1280            modules: input.modules,
1281            declared_deps: input.declared_deps,
1282            public_api_entry_points: input.public_api_entry_points,
1283            line_offsets_by_file: input.line_offsets_by_file,
1284            suppressions: input.suppressions,
1285            root: &input.config.root,
1286        })
1287        .into_iter()
1288        .map(UnrenderedComponentFinding::with_actions),
1289    );
1290}
1291
1292/// Populate `unused_component_props` when the rule is enabled. Gated on the
1293/// project declaring the matching framework dependency inside the detector (see
1294/// [`find_unused_component_props`]).
1295fn populate_unused_component_prop_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1296    if input.config.rules.unused_component_props == Severity::Off {
1297        return;
1298    }
1299    // Vue/Svelte arm: one component per SFC, flagged from `component_props`.
1300    input.results.unused_component_props = find_unused_component_props(
1301        input.graph,
1302        input.modules,
1303        input.declared_deps,
1304        input.line_offsets_by_file,
1305    )
1306    .into_iter()
1307    .map(UnusedComponentPropFinding::with_actions)
1308    .collect();
1309
1310    append_react_unused_component_prop_findings(input);
1311    retain_unsuppressed_unused_component_prop_findings(input);
1312}
1313
1314fn append_react_unused_component_prop_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1315    // React/Preact arm: another producer of the SAME finding kind, emitting into
1316    // the same vector. Gated on `react` / `react-dom` / `next` / `preact` inside
1317    // the producer.
1318    let react = find_unused_react_props(
1319        input.graph,
1320        input.modules,
1321        input.declared_deps,
1322        input.line_offsets_by_file,
1323    );
1324    if react.components_scanned > 0 {
1325        // Observability: make a silent dep-gate or silent abstain visible (a
1326        // scanned-but-zero-finding run is a clean bill, not a no-op). Surfaced at
1327        // info level so `RUST_LOG=fallow_core=info` shows it.
1328        tracing::info!(
1329            components_scanned = react.components_scanned,
1330            unused_props = react.findings.len(),
1331            "React detected, {} component(s) scanned for unused props",
1332            react.components_scanned
1333        );
1334    }
1335    input.results.unused_component_props.extend(
1336        react
1337            .findings
1338            .into_iter()
1339            .map(UnusedComponentPropFinding::with_actions),
1340    );
1341}
1342
1343fn retain_unsuppressed_unused_component_prop_findings(
1344    input: &mut FrameworkSpecificFindingsInput<'_>,
1345) {
1346    // Inline-suppression filter over ALL arms: a `// fallow-ignore-next-line
1347    // unused-component-prop` above the prop (or a file-level
1348    // `// fallow-ignore-file unused-component-prop`) drops the finding. The
1349    // finding's `path` is the absolute graph node path, so it maps directly to a
1350    // FileId for the line-anchored suppression check.
1351    let path_to_id = graph_file_ids_by_path(input.graph);
1352    input.results.unused_component_props.retain(|finding| {
1353        !path_line_is_suppressed(
1354            &path_to_id,
1355            input.suppressions,
1356            finding.prop.path.as_path(),
1357            finding.prop.line,
1358            IssueKind::UnusedComponentProp,
1359        )
1360    });
1361}
1362
1363/// Populate `unused_component_emits` when the rule is enabled. Gated on the
1364/// project declaring `vue` / `@vue/runtime-core` / `nuxt` inside the detector
1365/// (see [`find_unused_component_emits`]).
1366fn populate_unused_component_emit_findings(
1367    graph: &ModuleGraph,
1368    modules: &[ModuleInfo],
1369    config: &ResolvedConfig,
1370    declared_deps: &FxHashSet<String>,
1371    line_offsets_by_file: &LineOffsetsMap<'_>,
1372    results: &mut AnalysisResults,
1373) {
1374    if config.rules.unused_component_emits == Severity::Off {
1375        return;
1376    }
1377    results.unused_component_emits =
1378        find_unused_component_emits(graph, modules, declared_deps, line_offsets_by_file)
1379            .into_iter()
1380            .map(UnusedComponentEmitFinding::with_actions)
1381            .collect();
1382}
1383
1384/// Populate `prop_drilling_chains` when the rule is enabled. The rule defaults to
1385/// `off` (opt-in health signal), so this is dormant by default: the located
1386/// per-chain records and the small capped health penalty appear only once the
1387/// user sets `prop-drilling` to `warn`/`error`. Gated on the project declaring
1388/// `react` / `react-dom` / `next` / `preact` inside the detector (see
1389/// [`find_prop_drilling_chains`]).
1390fn populate_prop_drilling_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1391    if input.config.rules.prop_drilling == Severity::Off {
1392        return;
1393    }
1394    input.results.prop_drilling_chains = collect_prop_drilling_findings(input);
1395
1396    retain_unsuppressed_prop_drilling_findings(input);
1397}
1398
1399fn collect_prop_drilling_findings(
1400    input: &FrameworkSpecificFindingsInput<'_>,
1401) -> Vec<PropDrillingChainFinding> {
1402    let scan = find_prop_drilling_chains(
1403        input.graph,
1404        input.modules,
1405        input.resolved_modules,
1406        input.declared_deps,
1407        input.line_offsets_by_file,
1408    );
1409    if scan.components_scanned > 0 {
1410        // Observability: a scanned-but-zero run is a clean bill, not a no-op.
1411        tracing::info!(
1412            components_scanned = scan.components_scanned,
1413            prop_drilling_chains = scan.chains.len(),
1414            "React detected, {} component(s) scanned for prop drilling",
1415            scan.components_scanned
1416        );
1417    }
1418    scan.chains
1419        .into_iter()
1420        .map(PropDrillingChainFinding::with_actions)
1421        .collect()
1422}
1423
1424fn retain_unsuppressed_prop_drilling_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1425    // Inline-suppression filter: a `// fallow-ignore-next-line prop-drilling`
1426    // above the source prop declaration (or a file-level
1427    // `// fallow-ignore-file prop-drilling` on the source file) drops the chain.
1428    // The source hop's `file` is the absolute graph node path, so it maps to a
1429    // FileId for the line-anchored check.
1430    let path_to_id = graph_file_ids_by_path(input.graph);
1431    input.results.prop_drilling_chains.retain(|finding| {
1432        let Some(source) = finding.chain.hops.first() else {
1433            return true;
1434        };
1435        !path_line_is_suppressed(
1436            &path_to_id,
1437            input.suppressions,
1438            source.file.as_path(),
1439            source.line,
1440            IssueKind::PropDrilling,
1441        )
1442    });
1443}
1444
1445/// Populate `thin_wrappers` when the rule is enabled. The rule defaults to `off`
1446/// (opt-in health signal), so this is dormant by default: the located
1447/// per-wrapper records appear only once the user sets `thin-wrapper` to
1448/// `warn`/`error`. Gated on the project declaring `react` / `react-dom` / `next`
1449/// / `preact` inside the detector (see [`find_thin_wrappers`]).
1450fn populate_thin_wrapper_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1451    if input.config.rules.thin_wrapper == Severity::Off {
1452        return;
1453    }
1454    input.results.thin_wrappers = collect_thin_wrapper_findings(input);
1455
1456    retain_unsuppressed_thin_wrapper_findings(input);
1457}
1458
1459fn collect_thin_wrapper_findings(
1460    input: &FrameworkSpecificFindingsInput<'_>,
1461) -> Vec<ThinWrapperFinding> {
1462    let scan = find_thin_wrappers(
1463        input.graph,
1464        input.modules,
1465        input.resolved_modules,
1466        input.declared_deps,
1467        input.line_offsets_by_file,
1468    );
1469    if scan.components_scanned > 0 {
1470        // Observability: a scanned-but-zero run is a clean bill, not a no-op.
1471        tracing::info!(
1472            components_scanned = scan.components_scanned,
1473            thin_wrappers = scan.wrappers.len(),
1474            "React detected, {} component(s) scanned for thin wrappers",
1475            scan.components_scanned
1476        );
1477    }
1478    scan.wrappers
1479        .into_iter()
1480        .map(ThinWrapperFinding::with_actions)
1481        .collect()
1482}
1483
1484fn retain_unsuppressed_thin_wrapper_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1485    // Inline-suppression filter: a `// fallow-ignore-next-line thin-wrapper`
1486    // above the wrapper component definition (or a file-level
1487    // `// fallow-ignore-file thin-wrapper` on the wrapper's file) drops it. The
1488    // wrapper's `file` is the absolute graph node path, so it maps to a FileId
1489    // for the line-anchored check.
1490    let path_to_id = graph_file_ids_by_path(input.graph);
1491    input.results.thin_wrappers.retain(|finding| {
1492        !path_line_is_suppressed(
1493            &path_to_id,
1494            input.suppressions,
1495            finding.wrapper.file.as_path(),
1496            finding.wrapper.line,
1497            IssueKind::ThinWrapper,
1498        )
1499    });
1500}
1501
1502/// Populate `duplicate_prop_shapes` when the rule is enabled. The rule defaults
1503/// to `off` (opt-in structural-refactor health signal), so this is dormant by
1504/// default: the located per-component records appear only once the user sets
1505/// `duplicate-prop-shape` to `warn`/`error`. Gated on the project declaring
1506/// `react` / `react-dom` / `next` / `preact` inside the detector (see
1507/// [`find_duplicate_prop_shapes`]).
1508///
1509/// Multi-file suppress model (copied from route-collision): a per-member finding
1510/// is dropped by a line-level (`// fallow-ignore-next-line duplicate-prop-shape`
1511/// at its component definition) or a file-level
1512/// (`// fallow-ignore-file duplicate-prop-shape`) suppress, but the suppressed
1513/// member STILL appears in its siblings' `sharing_components`, because the
1514/// `sharing_components` roster is built at emit time (before this filter) and
1515/// the group is real regardless of suppression.
1516fn populate_duplicate_prop_shape_findings(input: &mut FrameworkSpecificFindingsInput<'_>) {
1517    if input.config.rules.duplicate_prop_shape == Severity::Off {
1518        return;
1519    }
1520    let scan = find_duplicate_prop_shapes(
1521        input.graph,
1522        input.modules,
1523        input.declared_deps,
1524        input.line_offsets_by_file,
1525    );
1526    if scan.components_scanned > 0 {
1527        // Observability: a scanned-but-zero run is a clean bill, not a no-op.
1528        tracing::info!(
1529            components_scanned = scan.components_scanned,
1530            duplicate_prop_shapes = scan.groups.len(),
1531            "React detected, {} component(s) scanned for duplicate prop shapes",
1532            scan.components_scanned
1533        );
1534    }
1535    input.results.duplicate_prop_shapes = scan
1536        .groups
1537        .into_iter()
1538        .map(DuplicatePropShapeFinding::with_actions)
1539        .collect();
1540
1541    // Inline-suppression filter: a line-level marker above the component
1542    // definition or a file-level marker on the component's file drops THIS
1543    // member; its slot in the siblings' `sharing_components` is unaffected (the
1544    // roster was built at emit time).
1545    let path_to_id = graph_file_ids_by_path(input.graph);
1546    input.results.duplicate_prop_shapes.retain(|finding| {
1547        !path_line_is_suppressed(
1548            &path_to_id,
1549            input.suppressions,
1550            finding.shape.file.as_path(),
1551            finding.shape.line,
1552            IssueKind::DuplicatePropShape,
1553        )
1554    });
1555}
1556
1557fn graph_file_ids_by_path(graph: &ModuleGraph) -> FxHashMap<&std::path::Path, FileId> {
1558    graph
1559        .modules
1560        .iter()
1561        .map(|node| (node.path.as_path(), node.file_id))
1562        .collect()
1563}
1564
1565fn path_line_is_suppressed(
1566    path_to_id: &FxHashMap<&std::path::Path, FileId>,
1567    suppressions: &SuppressionContext<'_>,
1568    path: &std::path::Path,
1569    line: u32,
1570    kind: IssueKind,
1571) -> bool {
1572    let Some(&file_id) = path_to_id.get(path) else {
1573        return false;
1574    };
1575    suppressions.is_suppressed(file_id, line, kind)
1576        || suppressions.is_file_suppressed(file_id, kind)
1577}
1578
1579/// Populate `unused_component_inputs` when the rule is enabled. Gated on the
1580/// project declaring `@angular/core` inside the detector (see
1581/// [`find_unused_component_inputs`]).
1582fn populate_unused_component_input_findings(
1583    graph: &ModuleGraph,
1584    modules: &[ModuleInfo],
1585    config: &ResolvedConfig,
1586    declared_deps: &FxHashSet<String>,
1587    line_offsets_by_file: &LineOffsetsMap<'_>,
1588    results: &mut AnalysisResults,
1589) {
1590    if config.rules.unused_component_inputs == Severity::Off {
1591        return;
1592    }
1593    results.unused_component_inputs =
1594        find_unused_component_inputs(graph, modules, declared_deps, line_offsets_by_file)
1595            .into_iter()
1596            .map(UnusedComponentInputFinding::with_actions)
1597            .collect();
1598}
1599
1600/// Populate `unused_component_outputs` when the rule is enabled. Gated on the
1601/// project declaring `@angular/core` inside the detector (see
1602/// [`find_unused_component_outputs`]).
1603fn populate_unused_component_output_findings(
1604    graph: &ModuleGraph,
1605    modules: &[ModuleInfo],
1606    config: &ResolvedConfig,
1607    declared_deps: &FxHashSet<String>,
1608    line_offsets_by_file: &LineOffsetsMap<'_>,
1609    results: &mut AnalysisResults,
1610) {
1611    if config.rules.unused_component_outputs == Severity::Off {
1612        return;
1613    }
1614    results.unused_component_outputs =
1615        find_unused_component_outputs(graph, modules, declared_deps, line_offsets_by_file)
1616            .into_iter()
1617            .map(UnusedComponentOutputFinding::with_actions)
1618            .collect();
1619}
1620
1621/// Populate `unused_svelte_events` when the rule is enabled. Gated on the
1622/// project declaring `svelte` inside the detector (see
1623/// [`find_unused_svelte_events`]).
1624fn populate_unused_svelte_event_findings(
1625    graph: &ModuleGraph,
1626    modules: &[ModuleInfo],
1627    config: &ResolvedConfig,
1628    declared_deps: &FxHashSet<String>,
1629    line_offsets_by_file: &LineOffsetsMap<'_>,
1630    results: &mut AnalysisResults,
1631) {
1632    if config.rules.unused_svelte_events == Severity::Off {
1633        return;
1634    }
1635    results.unused_svelte_events =
1636        find_unused_svelte_events(graph, modules, declared_deps, line_offsets_by_file)
1637            .into_iter()
1638            .map(UnusedSvelteEventFinding::with_actions)
1639            .collect();
1640}
1641
1642/// Populate `route_collisions` when the rule is enabled. Gated on the project
1643/// declaring `next` inside the detector (see [`find_route_collisions`]).
1644fn populate_route_collision_findings(
1645    graph: &ModuleGraph,
1646    config: &ResolvedConfig,
1647    workspaces: &[fallow_config::WorkspaceInfo],
1648    declared_deps: &FxHashSet<String>,
1649    suppressions: &SuppressionContext<'_>,
1650    results: &mut AnalysisResults,
1651) {
1652    if config.rules.route_collision == Severity::Off {
1653        return;
1654    }
1655    results.route_collisions =
1656        find_route_collisions(graph, config, workspaces, declared_deps, suppressions)
1657            .into_iter()
1658            .map(RouteCollisionFinding::with_actions)
1659            .collect();
1660}
1661
1662/// Populate `dynamic_segment_name_conflicts` when the rule is enabled. Gated on
1663/// the project declaring `next` inside the detector (see
1664/// [`find_dynamic_segment_name_conflicts`]).
1665fn populate_dynamic_segment_name_conflict_findings(
1666    graph: &ModuleGraph,
1667    config: &ResolvedConfig,
1668    workspaces: &[fallow_config::WorkspaceInfo],
1669    declared_deps: &FxHashSet<String>,
1670    suppressions: &SuppressionContext<'_>,
1671    results: &mut AnalysisResults,
1672) {
1673    if config.rules.dynamic_segment_name_conflict == Severity::Off {
1674        return;
1675    }
1676    results.dynamic_segment_name_conflicts =
1677        find_dynamic_segment_name_conflicts(graph, config, workspaces, declared_deps, suppressions)
1678            .into_iter()
1679            .map(DynamicSegmentNameConflictFinding::with_actions)
1680            .collect();
1681}
1682
1683/// Populate both Next.js App Router route-tree findings (`route_collisions` and
1684/// `dynamic_segment_name_conflicts`). Both share the same path-only primitive
1685/// (see [`crate::analyze::route_tree`]) and are gated on the project declaring
1686/// `next` inside their detectors.
1687fn populate_nextjs_route_tree_findings(
1688    graph: &ModuleGraph,
1689    config: &ResolvedConfig,
1690    workspaces: &[fallow_config::WorkspaceInfo],
1691    declared_deps: &FxHashSet<String>,
1692    suppressions: &SuppressionContext<'_>,
1693    results: &mut AnalysisResults,
1694) {
1695    populate_route_collision_findings(
1696        graph,
1697        config,
1698        workspaces,
1699        declared_deps,
1700        suppressions,
1701        results,
1702    );
1703    populate_dynamic_segment_name_conflict_findings(
1704        graph,
1705        config,
1706        workspaces,
1707        declared_deps,
1708        suppressions,
1709        results,
1710    );
1711}
1712
1713#[derive(Clone, Copy)]
1714struct DeadCodeDetectorInput<'a> {
1715    graph: &'a ModuleGraph,
1716    config: &'a ResolvedConfig,
1717    resolved_modules: &'a [ResolvedModule],
1718    workspaces: &'a [fallow_config::WorkspaceInfo],
1719    modules: &'a [ModuleInfo],
1720    suppressions: &'a SuppressionContext<'a>,
1721    line_offsets_by_file: &'a LineOffsetsMap<'a>,
1722    plugin_result: Option<&'a crate::plugins::AggregatedPluginResult>,
1723    pkg: Option<&'a PackageJson>,
1724    user_class_members: &'a [fallow_config::UsedClassMemberRule],
1725    public_api_entry_points: &'a FxHashSet<FileId>,
1726    virtual_prefixes: &'a [&'a str],
1727    generated_patterns: &'a [&'a str],
1728    generated_type_prefixes: &'a [&'a str],
1729    declared_deps: &'a FxHashSet<String>,
1730    collect_usages: bool,
1731}
1732
1733struct ParallelDeadCodeDetectorResults {
1734    unused_files: Vec<UnusedFileFinding>,
1735    export_results: AnalysisResults,
1736    member_results: AnalysisResults,
1737    dependency_results: AnalysisResults,
1738    unresolved_imports: Vec<UnresolvedImportFinding>,
1739    duplicate_exports: Vec<DuplicateExportFinding>,
1740    boundary_violations: Vec<BoundaryViolationFinding>,
1741    boundary_coverage_violations: Vec<BoundaryCoverageViolationFinding>,
1742    boundary_call_violations: Vec<BoundaryCallViolationFinding>,
1743    policy_violations: Vec<PolicyViolationFinding>,
1744    circular_dependencies: Vec<CircularDependencyFinding>,
1745    re_export_cycles: Vec<ReExportCycleFinding>,
1746    export_usages: Vec<crate::results::ExportUsage>,
1747}
1748
1749impl ParallelDeadCodeDetectorResults {
1750    fn into_analysis_results(self) -> AnalysisResults {
1751        AnalysisResults {
1752            unused_files: self.unused_files,
1753            unused_exports: self.export_results.unused_exports,
1754            unused_types: self.export_results.unused_types,
1755            private_type_leaks: self.export_results.private_type_leaks,
1756            stale_suppressions: self.export_results.stale_suppressions,
1757            unused_enum_members: self.member_results.unused_enum_members,
1758            unused_class_members: self.member_results.unused_class_members,
1759            unused_store_members: self.member_results.unused_store_members,
1760            unused_dependencies: self.dependency_results.unused_dependencies,
1761            unused_dev_dependencies: self.dependency_results.unused_dev_dependencies,
1762            unused_optional_dependencies: self.dependency_results.unused_optional_dependencies,
1763            unlisted_dependencies: self.dependency_results.unlisted_dependencies,
1764            type_only_dependencies: self.dependency_results.type_only_dependencies,
1765            test_only_dependencies: self.dependency_results.test_only_dependencies,
1766            unresolved_imports: self.unresolved_imports,
1767            duplicate_exports: self.duplicate_exports,
1768            boundary_violations: self.boundary_violations,
1769            boundary_coverage_violations: self.boundary_coverage_violations,
1770            boundary_call_violations: self.boundary_call_violations,
1771            policy_violations: self.policy_violations,
1772            circular_dependencies: self.circular_dependencies,
1773            re_export_cycles: self.re_export_cycles,
1774            export_usages: self.export_usages,
1775            ..AnalysisResults::default()
1776        }
1777    }
1778}
1779
1780fn run_parallel_dead_code_detectors(input: DeadCodeDetectorInput<'_>) -> AnalysisResults {
1781    collect_parallel_dead_code_detector_results(input).into_analysis_results()
1782}
1783
1784fn collect_parallel_dead_code_detector_results(
1785    input: DeadCodeDetectorInput<'_>,
1786) -> ParallelDeadCodeDetectorResults {
1787    let (
1788        (unused_files, export_results),
1789        (
1790            (member_results, dependency_results),
1791            (
1792                (unresolved_imports, duplicate_exports),
1793                (
1794                    (
1795                        boundary_violations,
1796                        (
1797                            boundary_coverage_violations,
1798                            (boundary_call_violations, policy_violations),
1799                        ),
1800                    ),
1801                    (circular_dependencies, (re_export_cycles, export_usages)),
1802                ),
1803            ),
1804        ),
1805    ) = rayon::join(
1806        || run_file_and_export_detectors(input),
1807        || {
1808            rayon::join(
1809                || run_member_and_dependency_detectors(input),
1810                || {
1811                    rayon::join(
1812                        || run_import_and_duplicate_detectors(input),
1813                        || run_boundary_cycle_and_usage_detectors(input),
1814                    )
1815                },
1816            )
1817        },
1818    );
1819
1820    ParallelDeadCodeDetectorResults {
1821        unused_files,
1822        export_results,
1823        member_results,
1824        dependency_results,
1825        unresolved_imports,
1826        duplicate_exports,
1827        boundary_violations,
1828        boundary_coverage_violations,
1829        boundary_call_violations,
1830        policy_violations,
1831        circular_dependencies,
1832        re_export_cycles,
1833        export_usages,
1834    }
1835}
1836
1837fn run_file_and_export_detectors(
1838    input: DeadCodeDetectorInput<'_>,
1839) -> (Vec<UnusedFileFinding>, AnalysisResults) {
1840    rayon::join(
1841        || run_unused_file_detector(input.graph, input.config, input.suppressions),
1842        || {
1843            run_export_detectors(
1844                input.graph,
1845                input.modules,
1846                input.config,
1847                input.plugin_result,
1848                input.suppressions,
1849                input.line_offsets_by_file,
1850            )
1851        },
1852    )
1853}
1854
1855fn run_member_and_dependency_detectors(
1856    input: DeadCodeDetectorInput<'_>,
1857) -> (AnalysisResults, AnalysisResults) {
1858    rayon::join(
1859        || {
1860            run_member_detectors(MemberDetectorInput {
1861                graph: input.graph,
1862                resolved_modules: input.resolved_modules,
1863                modules: input.modules,
1864                config: input.config,
1865                suppressions: input.suppressions,
1866                line_offsets_by_file: input.line_offsets_by_file,
1867                user_class_members: input.user_class_members,
1868                public_api_entry_points: input.public_api_entry_points,
1869                declared_deps: input.declared_deps,
1870            })
1871        },
1872        || {
1873            run_dependency_detectors(DependencyDetectorInput {
1874                graph: input.graph,
1875                pkg: input.pkg,
1876                config: input.config,
1877                plugin_result: input.plugin_result,
1878                workspaces: input.workspaces,
1879                resolved_modules: input.resolved_modules,
1880                line_offsets_by_file: input.line_offsets_by_file,
1881            })
1882        },
1883    )
1884}
1885
1886fn run_import_and_duplicate_detectors(
1887    input: DeadCodeDetectorInput<'_>,
1888) -> (Vec<UnresolvedImportFinding>, Vec<DuplicateExportFinding>) {
1889    rayon::join(
1890        || {
1891            run_unresolved_import_detector(UnresolvedImportDetectorInput {
1892                resolved_modules: input.resolved_modules,
1893                config: input.config,
1894                suppressions: input.suppressions,
1895                virtual_prefixes: input.virtual_prefixes,
1896                generated_patterns: input.generated_patterns,
1897                generated_type_prefixes: input.generated_type_prefixes,
1898                line_offsets_by_file: input.line_offsets_by_file,
1899            })
1900        },
1901        || {
1902            run_duplicate_export_detector(
1903                input.graph,
1904                input.config,
1905                input.suppressions,
1906                input.line_offsets_by_file,
1907                input.plugin_result,
1908                input.resolved_modules,
1909            )
1910        },
1911    )
1912}
1913
1914type BoundaryAuxResults = (
1915    Vec<BoundaryCoverageViolationFinding>,
1916    (
1917        Vec<BoundaryCallViolationFinding>,
1918        Vec<PolicyViolationFinding>,
1919    ),
1920);
1921
1922type BoundaryCycleUsageResults = (
1923    (Vec<BoundaryViolationFinding>, BoundaryAuxResults),
1924    (
1925        Vec<CircularDependencyFinding>,
1926        (Vec<ReExportCycleFinding>, Vec<crate::results::ExportUsage>),
1927    ),
1928);
1929
1930fn run_boundary_cycle_and_usage_detectors(
1931    input: DeadCodeDetectorInput<'_>,
1932) -> BoundaryCycleUsageResults {
1933    rayon::join(
1934        || run_boundary_detectors(input),
1935        || run_cycle_and_usage_detectors(input),
1936    )
1937}
1938
1939fn run_boundary_detectors(
1940    input: DeadCodeDetectorInput<'_>,
1941) -> (Vec<BoundaryViolationFinding>, BoundaryAuxResults) {
1942    rayon::join(
1943        || {
1944            run_boundary_violation_detector(
1945                input.graph,
1946                input.config,
1947                input.suppressions,
1948                input.line_offsets_by_file,
1949            )
1950        },
1951        || {
1952            run_boundary_aux_detectors(
1953                input.graph,
1954                input.modules,
1955                input.config,
1956                input.declared_deps,
1957                input.suppressions,
1958                input.line_offsets_by_file,
1959            )
1960        },
1961    )
1962}
1963
1964fn run_cycle_and_usage_detectors(
1965    input: DeadCodeDetectorInput<'_>,
1966) -> (
1967    Vec<CircularDependencyFinding>,
1968    (Vec<ReExportCycleFinding>, Vec<crate::results::ExportUsage>),
1969) {
1970    rayon::join(
1971        || {
1972            run_circular_dep_detector(
1973                input.graph,
1974                input.config,
1975                input.line_offsets_by_file,
1976                input.suppressions,
1977                input.workspaces,
1978            )
1979        },
1980        || {
1981            rayon::join(
1982                || run_re_export_cycle_detector(input.graph, input.config, input.suppressions),
1983                || {
1984                    run_export_usages_collector(
1985                        input.graph,
1986                        input.line_offsets_by_file,
1987                        input.collect_usages,
1988                    )
1989                },
1990            )
1991        },
1992    )
1993}
1994
1995#[expect(
1996    deprecated,
1997    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
1998)]
1999fn run_duplicate_export_detector(
2000    graph: &ModuleGraph,
2001    config: &ResolvedConfig,
2002    suppressions: &SuppressionContext<'_>,
2003    line_offsets_by_file: &LineOffsetsMap<'_>,
2004    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
2005    resolved_modules: &[ResolvedModule],
2006) -> Vec<DuplicateExportFinding> {
2007    if config.rules.duplicate_exports == Severity::Off {
2008        return Vec::new();
2009    }
2010    let duplicate_exports = if let Some(plugin_result) = plugin_result {
2011        unused_exports::find_duplicate_exports_with_plugins(
2012            graph,
2013            config,
2014            suppressions,
2015            line_offsets_by_file,
2016            Some(plugin_result),
2017            resolved_modules,
2018        )
2019    } else {
2020        unused_exports::find_duplicate_exports(
2021            graph,
2022            config,
2023            suppressions,
2024            line_offsets_by_file,
2025            resolved_modules,
2026        )
2027    };
2028    duplicate_exports
2029        .into_iter()
2030        .map(DuplicateExportFinding::with_actions)
2031        .collect()
2032}
2033
2034#[expect(
2035    deprecated,
2036    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
2037)]
2038fn run_boundary_violation_detector(
2039    graph: &ModuleGraph,
2040    config: &ResolvedConfig,
2041    suppressions: &SuppressionContext<'_>,
2042    line_offsets_by_file: &LineOffsetsMap<'_>,
2043) -> Vec<BoundaryViolationFinding> {
2044    if config.rules.boundary_violation == Severity::Off || config.boundaries.is_empty() {
2045        return Vec::new();
2046    }
2047    boundary::find_boundary_violations(graph, config, suppressions, line_offsets_by_file)
2048        .into_iter()
2049        .map(BoundaryViolationFinding::with_actions)
2050        .collect()
2051}
2052
2053fn filter_public_workspace_results(
2054    config: &ResolvedConfig,
2055    workspaces: &[fallow_config::WorkspaceInfo],
2056    results: &mut AnalysisResults,
2057) {
2058    let public_roots = public_workspace_roots(&config.public_packages, workspaces);
2059    if public_roots.is_empty() {
2060        return;
2061    }
2062    results.unused_exports.retain(|e| {
2063        !public_roots
2064            .iter()
2065            .any(|root| e.export.path.starts_with(root))
2066    });
2067    results.unused_types.retain(|e| {
2068        !public_roots
2069            .iter()
2070            .any(|root| e.export.path.starts_with(root))
2071    });
2072    results.unused_enum_members.retain(|e| {
2073        !public_roots
2074            .iter()
2075            .any(|root| e.member.path.starts_with(root))
2076    });
2077    results.unused_class_members.retain(|e| {
2078        !public_roots
2079            .iter()
2080            .any(|root| e.member.path.starts_with(root))
2081    });
2082}
2083
2084#[expect(
2085    deprecated,
2086    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
2087)]
2088fn populate_pnpm_catalog_findings(
2089    config: &ResolvedConfig,
2090    workspaces: &[fallow_config::WorkspaceInfo],
2091    results: &mut AnalysisResults,
2092) {
2093    let need_unused = config.rules.unused_catalog_entries != Severity::Off;
2094    let need_empty_groups = config.rules.empty_catalog_groups != Severity::Off;
2095    let need_unresolved_refs = config.rules.unresolved_catalog_references != Severity::Off;
2096    let Some(state) = ((need_unused || need_empty_groups || need_unresolved_refs)
2097        .then(|| gather_pnpm_catalog_state(config, workspaces)))
2098    .flatten() else {
2099        return;
2100    };
2101
2102    if need_unused {
2103        results.unused_catalog_entries = find_unused_catalog_entries(&state)
2104            .into_iter()
2105            .map(UnusedCatalogEntryFinding::with_actions)
2106            .collect();
2107    }
2108    if need_empty_groups {
2109        results.empty_catalog_groups = find_empty_catalog_groups(&state)
2110            .into_iter()
2111            .map(EmptyCatalogGroupFinding::with_actions)
2112            .collect();
2113    }
2114    if need_unresolved_refs {
2115        results.unresolved_catalog_references = find_unresolved_catalog_references(
2116            &state,
2117            &config.compiled_ignore_catalog_references,
2118            &config.root,
2119        )
2120        .into_iter()
2121        .map(UnresolvedCatalogReferenceFinding::with_actions)
2122        .collect();
2123    }
2124}
2125
2126#[expect(
2127    deprecated,
2128    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
2129)]
2130fn populate_pnpm_override_findings(
2131    config: &ResolvedConfig,
2132    workspaces: &[fallow_config::WorkspaceInfo],
2133    results: &mut AnalysisResults,
2134) {
2135    let need_unused = config.rules.unused_dependency_overrides != Severity::Off;
2136    let need_misconfigured = config.rules.misconfigured_dependency_overrides != Severity::Off;
2137    let Some(state) = ((need_unused || need_misconfigured)
2138        .then(|| gather_pnpm_override_state(config, workspaces)))
2139    .flatten() else {
2140        return;
2141    };
2142
2143    if need_unused {
2144        results.unused_dependency_overrides = find_unused_dependency_overrides(&state, config)
2145            .into_iter()
2146            .map(UnusedDependencyOverrideFinding::with_actions)
2147            .collect();
2148    }
2149    if need_misconfigured {
2150        results.misconfigured_dependency_overrides =
2151            find_misconfigured_dependency_overrides(&state, config)
2152                .into_iter()
2153                .map(MisconfiguredDependencyOverrideFinding::with_actions)
2154                .collect();
2155    }
2156}
2157
2158fn populate_security_findings(
2159    ctx: &SecurityDetectionContext<'_, '_>,
2160    results: &mut AnalysisResults,
2161) {
2162    if ctx.config.rules.security_client_server_leak != Severity::Off {
2163        let (security_findings, stats) = security::find_security_findings(
2164            ctx.graph,
2165            ctx.modules,
2166            ctx.suppressions,
2167            ctx.line_offsets_by_file,
2168        );
2169        results.security_findings = security_findings;
2170        results.security_unresolved_edge_files = stats.client_files_with_unresolved_edges;
2171    }
2172
2173    if ctx.config.rules.security_sink != Severity::Off {
2174        populate_tainted_sink_findings(ctx, results);
2175    }
2176
2177    if !results.security_findings.is_empty() {
2178        annotate_security_findings(ctx, results);
2179    }
2180}
2181
2182fn populate_tainted_sink_findings(
2183    ctx: &SecurityDetectionContext<'_, '_>,
2184    results: &mut AnalysisResults,
2185) {
2186    let categories = ctx.config.security.categories.as_ref();
2187    let filter = security::CategoryFilter::new(
2188        categories.and_then(|c| c.include.clone()),
2189        categories.and_then(|c| c.exclude.clone()),
2190    );
2191    let (sink_findings, sink_stats) = security::find_tainted_sinks(
2192        ctx.graph,
2193        ctx.modules,
2194        ctx.suppressions,
2195        ctx.line_offsets_by_file,
2196        ctx.declared_deps,
2197        &security::TaintedSinkContext {
2198            category_filter: &filter,
2199            request_receivers: ctx.request_receivers,
2200            root: &ctx.config.root,
2201        },
2202    );
2203    results.security_findings.extend(sink_findings);
2204    results.security_unresolved_callee_sites = sink_stats.sinks_skipped_dynamic_callee;
2205    results.security_unresolved_callee_diagnostics = sink_stats.unresolved_callee_diagnostics;
2206    results
2207        .security_findings
2208        .extend(security::find_hardcoded_secret_candidates(
2209            ctx.graph,
2210            ctx.modules,
2211            ctx.suppressions,
2212            ctx.line_offsets_by_file,
2213            &filter,
2214            &ctx.config.root,
2215        ));
2216}
2217
2218fn annotate_security_findings(
2219    ctx: &SecurityDetectionContext<'_, '_>,
2220    results: &mut AnalysisResults,
2221) {
2222    security::annotate_dead_code_cross_links(
2223        ctx.graph,
2224        ctx.modules,
2225        ctx.line_offsets_by_file,
2226        &results.unused_files,
2227        &results.unused_exports,
2228        &mut results.security_findings,
2229    );
2230    let boundary_crossings = boundary_crossings_by_file(&results.boundary_violations);
2231    security::rank_security_findings(
2232        &security::SecurityRankingInput {
2233            graph: ctx.graph,
2234            modules: ctx.modules,
2235            line_offsets_by_file: ctx.line_offsets_by_file,
2236            declared_deps: ctx.declared_deps,
2237            request_receivers: ctx.request_receivers,
2238            boundary_crossings: &boundary_crossings,
2239        },
2240        &mut results.security_findings,
2241    );
2242}
2243
2244fn boundary_crossings_by_file(
2245    boundary_violations: &[BoundaryViolationFinding],
2246) -> FxHashMap<std::path::PathBuf, (String, String)> {
2247    let mut boundary_crossings: FxHashMap<std::path::PathBuf, (String, String)> =
2248        FxHashMap::default();
2249    for violation in boundary_violations {
2250        let zones = (
2251            violation.violation.from_zone.clone(),
2252            violation.violation.to_zone.clone(),
2253        );
2254        for path in [
2255            violation.violation.from_path.clone(),
2256            violation.violation.to_path.clone(),
2257        ] {
2258            boundary_crossings
2259                .entry(path)
2260                .and_modify(|existing| {
2261                    if zones < *existing {
2262                        *existing = zones.clone();
2263                    }
2264                })
2265                .or_insert_with(|| zones.clone());
2266        }
2267    }
2268    boundary_crossings
2269}
2270
2271#[expect(
2272    deprecated,
2273    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
2274)]
2275fn run_unused_file_detector(
2276    graph: &ModuleGraph,
2277    config: &ResolvedConfig,
2278    suppressions: &crate::suppress::SuppressionContext<'_>,
2279) -> Vec<UnusedFileFinding> {
2280    if config.rules.unused_files == Severity::Off {
2281        return Vec::new();
2282    }
2283    find_unused_files(graph, suppressions)
2284        .into_iter()
2285        .map(UnusedFileFinding::with_actions)
2286        .collect()
2287}
2288
2289#[expect(
2290    deprecated,
2291    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
2292)]
2293fn run_export_detectors(
2294    graph: &ModuleGraph,
2295    modules: &[ModuleInfo],
2296    config: &ResolvedConfig,
2297    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
2298    suppressions: &crate::suppress::SuppressionContext<'_>,
2299    line_offsets_by_file: &LineOffsetsMap<'_>,
2300) -> AnalysisResults {
2301    let mut results = AnalysisResults::default();
2302    if export_rules_are_disabled(config) {
2303        return results;
2304    }
2305
2306    let (exports, types, stale_expected) = find_unused_exports(
2307        graph,
2308        modules,
2309        config,
2310        plugin_result,
2311        suppressions,
2312        line_offsets_by_file,
2313    );
2314    populate_unused_export_findings(&mut results, config, exports);
2315    populate_unused_type_findings(&mut results, config, graph, modules, types);
2316    populate_private_type_leak_findings(
2317        &mut results,
2318        graph,
2319        modules,
2320        config,
2321        suppressions,
2322        line_offsets_by_file,
2323    );
2324    populate_expected_stale_suppressions(&mut results, config, stale_expected);
2325    results
2326}
2327
2328fn export_rules_are_disabled(config: &ResolvedConfig) -> bool {
2329    config.rules.unused_exports == Severity::Off
2330        && config.rules.unused_types == Severity::Off
2331        && config.rules.private_type_leaks == Severity::Off
2332}
2333
2334fn populate_unused_export_findings(
2335    results: &mut AnalysisResults,
2336    config: &ResolvedConfig,
2337    exports: Vec<UnusedExport>,
2338) {
2339    if config.rules.unused_exports == Severity::Off {
2340        return;
2341    }
2342    results.unused_exports = exports
2343        .into_iter()
2344        .map(UnusedExportFinding::with_actions)
2345        .collect();
2346}
2347
2348fn populate_unused_type_findings(
2349    results: &mut AnalysisResults,
2350    config: &ResolvedConfig,
2351    graph: &ModuleGraph,
2352    modules: &[ModuleInfo],
2353    types: Vec<UnusedExport>,
2354) {
2355    if config.rules.unused_types == Severity::Off {
2356        return;
2357    }
2358    let mut typed = types;
2359    suppress_signature_backing_types(&mut typed, graph, modules);
2360    results.unused_types = typed
2361        .into_iter()
2362        .map(UnusedTypeFinding::with_actions)
2363        .collect();
2364}
2365
2366fn populate_private_type_leak_findings(
2367    results: &mut AnalysisResults,
2368    graph: &ModuleGraph,
2369    modules: &[ModuleInfo],
2370    config: &ResolvedConfig,
2371    suppressions: &crate::suppress::SuppressionContext<'_>,
2372    line_offsets_by_file: &LineOffsetsMap<'_>,
2373) {
2374    if config.rules.private_type_leaks == Severity::Off {
2375        return;
2376    }
2377    results.private_type_leaks =
2378        find_private_type_leaks(graph, modules, config, suppressions, line_offsets_by_file)
2379            .into_iter()
2380            .map(PrivateTypeLeakFinding::with_actions)
2381            .collect();
2382}
2383
2384fn populate_expected_stale_suppressions(
2385    results: &mut AnalysisResults,
2386    config: &ResolvedConfig,
2387    stale_expected: Vec<StaleSuppression>,
2388) {
2389    if config.rules.stale_suppressions != Severity::Off {
2390        results.stale_suppressions.extend(stale_expected);
2391    } else if config.rules.require_suppression_reason != Severity::Off {
2392        results
2393            .stale_suppressions
2394            .extend(stale_expected.into_iter().filter(|s| s.missing_reason));
2395    }
2396}
2397
2398#[derive(Clone, Copy)]
2399struct MemberDetectorInput<'a> {
2400    graph: &'a ModuleGraph,
2401    resolved_modules: &'a [ResolvedModule],
2402    modules: &'a [ModuleInfo],
2403    config: &'a ResolvedConfig,
2404    suppressions: &'a crate::suppress::SuppressionContext<'a>,
2405    line_offsets_by_file: &'a LineOffsetsMap<'a>,
2406    user_class_members: &'a [fallow_config::UsedClassMemberRule],
2407    public_api_entry_points: &'a FxHashSet<FileId>,
2408    declared_deps: &'a FxHashSet<String>,
2409}
2410
2411fn run_member_detectors(input: MemberDetectorInput<'_>) -> AnalysisResults {
2412    let mut results = AnalysisResults::default();
2413    let store_members_active = store_member_rule_is_active(input.config, input.declared_deps);
2414    if member_rules_are_disabled(input.config, store_members_active) {
2415        return results;
2416    }
2417
2418    let member_results = find_unused_members_with_public_api_entry_points(UnusedMemberScanInput {
2419        graph: input.graph,
2420        resolved_modules: input.resolved_modules,
2421        modules: input.modules,
2422        suppressions: input.suppressions,
2423        line_offsets_by_file: input.line_offsets_by_file,
2424        user_class_member_allowlist: input.user_class_members,
2425        ignore_decorators: &input.config.ignore_decorators,
2426        public_api_entry_points: input.public_api_entry_points,
2427        lit_active: input.declared_deps.contains("lit")
2428            || input.declared_deps.contains("lit-element")
2429            || input.declared_deps.contains("@lit/reactive-element"),
2430    });
2431    populate_unused_enum_member_findings(&mut results, input.config, member_results.enum_members);
2432    populate_unused_class_member_findings(&mut results, input.config, member_results.class_members);
2433    populate_unused_store_member_findings(
2434        &mut results,
2435        store_members_active,
2436        member_results.store_members,
2437    );
2438    results
2439}
2440
2441fn member_rules_are_disabled(config: &ResolvedConfig, store_members_active: bool) -> bool {
2442    config.rules.unused_enum_members == Severity::Off
2443        && config.rules.unused_class_members == Severity::Off
2444        && !store_members_active
2445}
2446
2447fn store_member_rule_is_active(config: &ResolvedConfig, declared_deps: &FxHashSet<String>) -> bool {
2448    // Store-member detection activates only when Pinia is a declared dependency,
2449    // so an unrelated user `defineStore`-named helper in a non-Pinia project
2450    // never fires. The harvest is intentionally loose at extraction time; this
2451    // is the activation boundary.
2452    config.rules.unused_store_members != Severity::Off
2453        && (declared_deps.contains("pinia") || declared_deps.contains("@pinia/nuxt"))
2454}
2455
2456fn populate_unused_enum_member_findings(
2457    results: &mut AnalysisResults,
2458    config: &ResolvedConfig,
2459    enum_members: Vec<UnusedMember>,
2460) {
2461    if config.rules.unused_enum_members == Severity::Off {
2462        return;
2463    }
2464    results.unused_enum_members = enum_members
2465        .into_iter()
2466        .map(UnusedEnumMemberFinding::with_actions)
2467        .collect();
2468}
2469
2470fn populate_unused_class_member_findings(
2471    results: &mut AnalysisResults,
2472    config: &ResolvedConfig,
2473    class_members: Vec<UnusedMember>,
2474) {
2475    if config.rules.unused_class_members == Severity::Off {
2476        return;
2477    }
2478    results.unused_class_members = class_members
2479        .into_iter()
2480        .map(UnusedClassMemberFinding::with_actions)
2481        .collect();
2482}
2483
2484fn populate_unused_store_member_findings(
2485    results: &mut AnalysisResults,
2486    store_members_active: bool,
2487    store_members: Vec<UnusedMember>,
2488) {
2489    if !store_members_active {
2490        return;
2491    }
2492    results.unused_store_members = store_members
2493        .into_iter()
2494        .map(UnusedStoreMemberFinding::with_actions)
2495        .collect();
2496}
2497
2498#[derive(Clone, Copy)]
2499struct DependencyDetectorInput<'a> {
2500    graph: &'a ModuleGraph,
2501    pkg: Option<&'a PackageJson>,
2502    config: &'a ResolvedConfig,
2503    plugin_result: Option<&'a crate::plugins::AggregatedPluginResult>,
2504    workspaces: &'a [fallow_config::WorkspaceInfo],
2505    resolved_modules: &'a [ResolvedModule],
2506    line_offsets_by_file: &'a LineOffsetsMap<'a>,
2507}
2508
2509fn run_dependency_detectors(input: DependencyDetectorInput<'_>) -> AnalysisResults {
2510    let mut results = AnalysisResults::default();
2511    let Some(pkg) = input.pkg else {
2512        return results;
2513    };
2514
2515    populate_unused_dependency_findings(input, pkg, &mut results);
2516    populate_unlisted_dependency_findings(input, pkg, &mut results);
2517    populate_type_only_dependency_findings(input, pkg, &mut results);
2518    populate_test_only_dependency_findings(input, pkg, &mut results);
2519    results
2520}
2521
2522fn populate_unlisted_dependency_findings(
2523    input: DependencyDetectorInput<'_>,
2524    pkg: &PackageJson,
2525    results: &mut AnalysisResults,
2526) {
2527    if input.config.rules.unlisted_dependencies != Severity::Off {
2528        results.unlisted_dependencies = find_unlisted_dependencies(UnlistedDependencyInput {
2529            graph: input.graph,
2530            pkg,
2531            config: input.config,
2532            workspaces: input.workspaces,
2533            plugin_result: input.plugin_result,
2534            resolved_modules: input.resolved_modules,
2535            line_offsets_by_file: input.line_offsets_by_file,
2536        })
2537        .into_iter()
2538        .map(UnlistedDependencyFinding::with_actions)
2539        .collect();
2540    }
2541}
2542
2543fn populate_type_only_dependency_findings(
2544    input: DependencyDetectorInput<'_>,
2545    pkg: &PackageJson,
2546    results: &mut AnalysisResults,
2547) {
2548    if input.config.production {
2549        results.type_only_dependencies =
2550            find_type_only_dependencies(input.graph, pkg, input.config, input.workspaces)
2551                .into_iter()
2552                .map(TypeOnlyDependencyFinding::with_actions)
2553                .collect();
2554    }
2555}
2556
2557fn populate_test_only_dependency_findings(
2558    input: DependencyDetectorInput<'_>,
2559    pkg: &PackageJson,
2560    results: &mut AnalysisResults,
2561) {
2562    if !input.config.production && input.config.rules.test_only_dependencies != Severity::Off {
2563        results.test_only_dependencies =
2564            find_test_only_dependencies(input.graph, pkg, input.config, input.workspaces)
2565                .into_iter()
2566                .map(TestOnlyDependencyFinding::with_actions)
2567                .collect();
2568    }
2569}
2570
2571/// Populate the unused-dependency family (prod / dev / optional) on `results`,
2572/// each gated on its own rule severity. The three collections share one
2573/// `find_unused_dependencies` computation, so they are populated together.
2574#[expect(
2575    deprecated,
2576    reason = "ADR-008 deprecates detector helpers for external callers; core orchestration still calls them internally"
2577)]
2578fn populate_unused_dependency_findings(
2579    input: DependencyDetectorInput<'_>,
2580    pkg: &PackageJson,
2581    results: &mut AnalysisResults,
2582) {
2583    if unused_dependency_rules_are_disabled(input.config) {
2584        return;
2585    }
2586
2587    let (deps, dev_deps, optional_deps) = find_unused_dependencies(
2588        input.graph,
2589        pkg,
2590        input.config,
2591        input.plugin_result,
2592        input.workspaces,
2593    );
2594    populate_unused_prod_dependency_findings(results, input.config, deps);
2595    populate_unused_dev_dependency_findings(results, input.config, dev_deps);
2596    populate_unused_optional_dependency_findings(results, input.config, optional_deps);
2597}
2598
2599fn unused_dependency_rules_are_disabled(config: &ResolvedConfig) -> bool {
2600    config.rules.unused_dependencies == Severity::Off
2601        && config.rules.unused_dev_dependencies == Severity::Off
2602        && config.rules.unused_optional_dependencies == Severity::Off
2603}
2604
2605fn populate_unused_prod_dependency_findings(
2606    results: &mut AnalysisResults,
2607    config: &ResolvedConfig,
2608    deps: Vec<UnusedDependency>,
2609) {
2610    if config.rules.unused_dependencies == Severity::Off {
2611        return;
2612    }
2613    results.unused_dependencies = deps
2614        .into_iter()
2615        .map(UnusedDependencyFinding::with_actions)
2616        .collect();
2617}
2618
2619fn populate_unused_dev_dependency_findings(
2620    results: &mut AnalysisResults,
2621    config: &ResolvedConfig,
2622    dev_deps: Vec<UnusedDependency>,
2623) {
2624    if config.rules.unused_dev_dependencies == Severity::Off {
2625        return;
2626    }
2627    results.unused_dev_dependencies = dev_deps
2628        .into_iter()
2629        .map(UnusedDevDependencyFinding::with_actions)
2630        .collect();
2631}
2632
2633fn populate_unused_optional_dependency_findings(
2634    results: &mut AnalysisResults,
2635    config: &ResolvedConfig,
2636    optional_deps: Vec<UnusedDependency>,
2637) {
2638    if config.rules.unused_optional_dependencies == Severity::Off {
2639        return;
2640    }
2641    results.unused_optional_dependencies = optional_deps
2642        .into_iter()
2643        .map(UnusedOptionalDependencyFinding::with_actions)
2644        .collect();
2645}
2646
2647#[derive(Clone, Copy)]
2648struct UnresolvedImportDetectorInput<'a> {
2649    resolved_modules: &'a [ResolvedModule],
2650    config: &'a ResolvedConfig,
2651    suppressions: &'a crate::suppress::SuppressionContext<'a>,
2652    virtual_prefixes: &'a [&'a str],
2653    generated_patterns: &'a [&'a str],
2654    generated_type_prefixes: &'a [&'a str],
2655    line_offsets_by_file: &'a LineOffsetsMap<'a>,
2656}
2657
2658fn run_unresolved_import_detector(
2659    input: UnresolvedImportDetectorInput<'_>,
2660) -> Vec<UnresolvedImportFinding> {
2661    if input.config.rules.unresolved_imports == Severity::Off || input.resolved_modules.is_empty() {
2662        return Vec::new();
2663    }
2664    find_unresolved_imports(
2665        input.resolved_modules,
2666        input.config,
2667        input.suppressions,
2668        input.virtual_prefixes,
2669        input.generated_patterns,
2670        input.generated_type_prefixes,
2671        input.line_offsets_by_file,
2672    )
2673    .into_iter()
2674    .map(UnresolvedImportFinding::with_actions)
2675    .collect()
2676}
2677
2678#[cfg(test)]
2679#[expect(
2680    deprecated,
2681    reason = "ADR-008 keeps direct analyzer unit tests while the public warning targets external callers"
2682)]
2683mod tests {
2684    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
2685
2686    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
2687        let offsets = compute_line_offsets(source);
2688        byte_offset_to_line_col(&offsets, byte_offset)
2689    }
2690
2691    #[test]
2692    fn compute_offsets_empty() {
2693        assert_eq!(compute_line_offsets(""), vec![0]);
2694    }
2695
2696    #[test]
2697    fn compute_offsets_single_line() {
2698        assert_eq!(compute_line_offsets("hello"), vec![0]);
2699    }
2700
2701    #[test]
2702    fn compute_offsets_multiline() {
2703        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
2704    }
2705
2706    #[test]
2707    fn compute_offsets_trailing_newline() {
2708        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
2709    }
2710
2711    #[test]
2712    fn compute_offsets_crlf() {
2713        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
2714    }
2715
2716    #[test]
2717    fn compute_offsets_consecutive_newlines() {
2718        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
2719    }
2720
2721    #[test]
2722    fn byte_offset_empty_source() {
2723        assert_eq!(line_col("", 0), (1, 0));
2724    }
2725
2726    #[test]
2727    fn byte_offset_single_line_start() {
2728        assert_eq!(line_col("hello", 0), (1, 0));
2729    }
2730
2731    #[test]
2732    fn byte_offset_single_line_middle() {
2733        assert_eq!(line_col("hello", 4), (1, 4));
2734    }
2735
2736    #[test]
2737    fn byte_offset_multiline_start_of_line2() {
2738        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
2739    }
2740
2741    #[test]
2742    fn byte_offset_multiline_middle_of_line3() {
2743        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
2744    }
2745
2746    #[test]
2747    fn byte_offset_at_newline_boundary() {
2748        assert_eq!(line_col("line1\nline2", 5), (1, 5));
2749    }
2750
2751    #[test]
2752    fn byte_offset_multibyte_utf8() {
2753        let source = "hi\n\u{1F600}x";
2754        assert_eq!(line_col(source, 3), (2, 0));
2755        assert_eq!(line_col(source, 7), (2, 4));
2756    }
2757
2758    #[test]
2759    fn byte_offset_multibyte_accented_chars() {
2760        let source = "caf\u{00E9}\nbar";
2761        assert_eq!(line_col(source, 6), (2, 0));
2762        assert_eq!(line_col(source, 3), (1, 3));
2763    }
2764
2765    #[test]
2766    fn byte_offset_via_map_fallback() {
2767        use super::*;
2768        let map: LineOffsetsMap<'_> = FxHashMap::default();
2769        assert_eq!(
2770            super::byte_offset_to_line_col(&map, FileId(99), 42),
2771            (1, 42)
2772        );
2773    }
2774
2775    #[test]
2776    fn byte_offset_via_map_lookup() {
2777        use super::*;
2778        let offsets = compute_line_offsets("abc\ndef\nghi");
2779        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
2780        map.insert(FileId(0), &offsets);
2781        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
2782    }
2783
2784    mod orchestration {
2785        use super::super::*;
2786        use fallow_config::{FallowConfig, OutputFormat, RulesConfig, Severity};
2787        use std::path::PathBuf;
2788
2789        fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
2790            find_dead_code_full(graph, config, &[], None, &[], &[], false)
2791        }
2792
2793        fn make_config_with_rules(rules: RulesConfig) -> ResolvedConfig {
2794            FallowConfig {
2795                rules,
2796                ..Default::default()
2797            }
2798            .resolve(
2799                PathBuf::from("/tmp/orchestration-test"),
2800                OutputFormat::Human,
2801                1,
2802                true,
2803                true,
2804                None,
2805            )
2806        }
2807
2808        #[test]
2809        fn find_dead_code_all_rules_off_returns_empty() {
2810            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
2811            use crate::graph::ModuleGraph;
2812            use crate::resolve::ResolvedModule;
2813            use rustc_hash::FxHashSet;
2814
2815            let files = vec![DiscoveredFile {
2816                id: FileId(0),
2817                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
2818                size_bytes: 100,
2819            }];
2820            let entry_points = vec![EntryPoint {
2821                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
2822                source: EntryPointSource::ManualEntry,
2823            }];
2824            let resolved = vec![ResolvedModule {
2825                file_id: FileId(0),
2826                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
2827                exports: vec![],
2828                re_exports: vec![],
2829                resolved_imports: vec![],
2830                resolved_dynamic_imports: vec![],
2831                resolved_dynamic_patterns: vec![],
2832                member_accesses: vec![],
2833                semantic_facts: Box::default(),
2834                whole_object_uses: Box::default(),
2835                has_cjs_exports: false,
2836                has_angular_component_template_url: false,
2837                unused_import_bindings: FxHashSet::default(),
2838                type_referenced_import_bindings: vec![],
2839                value_referenced_import_bindings: vec![],
2840                namespace_object_aliases: vec![],
2841                exported_factory_returns: Box::default(),
2842            }];
2843            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
2844
2845            let rules = RulesConfig {
2846                unused_files: Severity::Off,
2847                unused_exports: Severity::Off,
2848                unused_types: Severity::Off,
2849                private_type_leaks: Severity::Off,
2850                unused_dependencies: Severity::Off,
2851                unused_dev_dependencies: Severity::Off,
2852                unused_optional_dependencies: Severity::Off,
2853                unused_enum_members: Severity::Off,
2854                unused_class_members: Severity::Off,
2855                unused_store_members: Severity::Off,
2856                unprovided_injects: Severity::Off,
2857                unrendered_components: Severity::Off,
2858                unused_component_props: Severity::Off,
2859                unused_component_emits: Severity::Off,
2860                unused_component_inputs: Severity::Off,
2861                unused_component_outputs: Severity::Off,
2862                unused_svelte_events: Severity::Off,
2863                unused_server_actions: Severity::Off,
2864                unused_load_data_keys: Severity::Off,
2865                prop_drilling: Severity::Off,
2866                thin_wrapper: Severity::Off,
2867                duplicate_prop_shape: Severity::Off,
2868                unresolved_imports: Severity::Off,
2869                unlisted_dependencies: Severity::Off,
2870                duplicate_exports: Severity::Off,
2871                type_only_dependencies: Severity::Off,
2872                circular_dependencies: Severity::Off,
2873                re_export_cycle: Severity::Off,
2874                test_only_dependencies: Severity::Off,
2875                boundary_violation: Severity::Off,
2876                coverage_gaps: Severity::Off,
2877                feature_flags: Severity::Off,
2878                stale_suppressions: Severity::Off,
2879                require_suppression_reason: Severity::Off,
2880                unused_catalog_entries: Severity::Off,
2881                empty_catalog_groups: Severity::Off,
2882                unresolved_catalog_references: Severity::Off,
2883                unused_dependency_overrides: Severity::Off,
2884                misconfigured_dependency_overrides: Severity::Off,
2885                security_client_server_leak: Severity::Off,
2886                security_sink: Severity::Off,
2887                policy_violation: Severity::Off,
2888                invalid_client_export: Severity::Off,
2889                mixed_client_server_barrel: Severity::Off,
2890                misplaced_directive: Severity::Off,
2891                route_collision: Severity::Off,
2892                dynamic_segment_name_conflict: Severity::Off,
2893            };
2894            let config = make_config_with_rules(rules);
2895            let results = find_dead_code(&graph, &config);
2896
2897            assert!(results.unused_files.is_empty());
2898            assert!(results.unused_exports.is_empty());
2899            assert!(results.unused_types.is_empty());
2900            assert!(results.unused_dependencies.is_empty());
2901            assert!(results.unused_dev_dependencies.is_empty());
2902            assert!(results.unused_optional_dependencies.is_empty());
2903            assert!(results.unused_enum_members.is_empty());
2904            assert!(results.unused_class_members.is_empty());
2905            assert!(results.unresolved_imports.is_empty());
2906            assert!(results.unlisted_dependencies.is_empty());
2907            assert!(results.duplicate_exports.is_empty());
2908            assert!(results.circular_dependencies.is_empty());
2909            assert!(results.export_usages.is_empty());
2910        }
2911
2912        #[test]
2913        fn find_dead_code_full_collect_usages_flag() {
2914            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
2915            use crate::extract::{ExportName, VisibilityTag};
2916            use crate::graph::{ExportSymbol, ModuleGraph};
2917            use crate::resolve::ResolvedModule;
2918            use oxc_span::Span;
2919            use rustc_hash::FxHashSet;
2920
2921            let files = vec![DiscoveredFile {
2922                id: FileId(0),
2923                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
2924                size_bytes: 100,
2925            }];
2926            let entry_points = vec![EntryPoint {
2927                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
2928                source: EntryPointSource::ManualEntry,
2929            }];
2930            let resolved = vec![ResolvedModule {
2931                file_id: FileId(0),
2932                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
2933                exports: vec![],
2934                re_exports: vec![],
2935                resolved_imports: vec![],
2936                resolved_dynamic_imports: vec![],
2937                resolved_dynamic_patterns: vec![],
2938                member_accesses: vec![],
2939                semantic_facts: Box::default(),
2940                whole_object_uses: Box::default(),
2941                has_cjs_exports: false,
2942                has_angular_component_template_url: false,
2943                unused_import_bindings: FxHashSet::default(),
2944                type_referenced_import_bindings: vec![],
2945                value_referenced_import_bindings: vec![],
2946                namespace_object_aliases: vec![],
2947                exported_factory_returns: Box::default(),
2948            }];
2949            let mut graph = ModuleGraph::build(&resolved, &entry_points, &files);
2950            graph.modules[0].exports = vec![ExportSymbol {
2951                name: ExportName::Named("myExport".to_string()),
2952                is_type_only: false,
2953                is_side_effect_used: false,
2954                visibility: VisibilityTag::None,
2955                expected_unused_reason: None,
2956                span: Span::new(10, 30),
2957                references: vec![],
2958                members: vec![],
2959            }];
2960
2961            let rules = RulesConfig::default();
2962            let config = make_config_with_rules(rules);
2963
2964            let results_no_collect = find_dead_code_full(
2965                &graph,
2966                &config,
2967                &[],
2968                None,
2969                &[],
2970                &[],
2971                false, // collect_usages = false
2972            );
2973            assert!(
2974                results_no_collect.export_usages.is_empty(),
2975                "export_usages should be empty when collect_usages is false"
2976            );
2977
2978            let results_with_collect = find_dead_code_full(
2979                &graph,
2980                &config,
2981                &[],
2982                None,
2983                &[],
2984                &[],
2985                true, // collect_usages = true
2986            );
2987            assert!(
2988                !results_with_collect.export_usages.is_empty(),
2989                "export_usages should be populated when collect_usages is true"
2990            );
2991            assert_eq!(
2992                results_with_collect.export_usages[0].export_name,
2993                "myExport"
2994            );
2995        }
2996
2997        #[test]
2998        fn find_dead_code_delegates_to_find_dead_code_with_resolved() {
2999            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
3000            use crate::graph::ModuleGraph;
3001            use crate::resolve::ResolvedModule;
3002            use rustc_hash::FxHashSet;
3003
3004            let files = vec![DiscoveredFile {
3005                id: FileId(0),
3006                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
3007                size_bytes: 100,
3008            }];
3009            let entry_points = vec![EntryPoint {
3010                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
3011                source: EntryPointSource::ManualEntry,
3012            }];
3013            let resolved = vec![ResolvedModule {
3014                file_id: FileId(0),
3015                path: PathBuf::from("/tmp/orchestration-test/src/index.ts"),
3016                exports: vec![],
3017                re_exports: vec![],
3018                resolved_imports: vec![],
3019                resolved_dynamic_imports: vec![],
3020                resolved_dynamic_patterns: vec![],
3021                member_accesses: vec![],
3022                semantic_facts: Box::default(),
3023                whole_object_uses: Box::default(),
3024                has_cjs_exports: false,
3025                has_angular_component_template_url: false,
3026                unused_import_bindings: FxHashSet::default(),
3027                type_referenced_import_bindings: vec![],
3028                value_referenced_import_bindings: vec![],
3029                namespace_object_aliases: vec![],
3030                exported_factory_returns: Box::default(),
3031            }];
3032            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
3033            let config = make_config_with_rules(RulesConfig::default());
3034
3035            let results = find_dead_code(&graph, &config);
3036            assert!(results.unused_exports.is_empty());
3037        }
3038
3039        #[test]
3040        #[expect(
3041            clippy::too_many_lines,
3042            reason = "test fixture; linear setup/assert, length is not a maintainability concern"
3043        )]
3044        fn suppressions_built_from_modules() {
3045            use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
3046            use crate::extract::ModuleInfo;
3047            use crate::graph::ModuleGraph;
3048            use crate::resolve::ResolvedModule;
3049            use crate::suppress::{IssueKind, Suppression};
3050            use rustc_hash::FxHashSet;
3051
3052            let files = vec![
3053                DiscoveredFile {
3054                    id: FileId(0),
3055                    path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
3056                    size_bytes: 100,
3057                },
3058                DiscoveredFile {
3059                    id: FileId(1),
3060                    path: PathBuf::from("/tmp/orchestration-test/src/utils.ts"),
3061                    size_bytes: 100,
3062                },
3063            ];
3064            let entry_points = vec![EntryPoint {
3065                path: PathBuf::from("/tmp/orchestration-test/src/entry.ts"),
3066                source: EntryPointSource::ManualEntry,
3067            }];
3068            let resolved = files
3069                .iter()
3070                .map(|f| ResolvedModule {
3071                    file_id: f.id,
3072                    path: f.path.clone(),
3073                    exports: vec![],
3074                    re_exports: vec![],
3075                    resolved_imports: vec![],
3076                    resolved_dynamic_imports: vec![],
3077                    resolved_dynamic_patterns: vec![],
3078                    member_accesses: vec![],
3079                    semantic_facts: Box::default(),
3080                    whole_object_uses: Box::default(),
3081                    has_cjs_exports: false,
3082                    has_angular_component_template_url: false,
3083                    unused_import_bindings: FxHashSet::default(),
3084                    type_referenced_import_bindings: vec![],
3085                    value_referenced_import_bindings: vec![],
3086                    namespace_object_aliases: vec![],
3087                    exported_factory_returns: Box::default(),
3088                })
3089                .collect::<Vec<_>>();
3090            let graph = ModuleGraph::build(&resolved, &entry_points, &files);
3091
3092            let modules = vec![ModuleInfo {
3093                file_id: FileId(1),
3094                exports: vec![],
3095                imports: vec![],
3096                re_exports: vec![],
3097                dynamic_imports: vec![],
3098                dynamic_import_patterns: vec![],
3099                require_calls: vec![],
3100                package_path_references: Box::default(),
3101                member_accesses: vec![],
3102                semantic_facts: Box::default(),
3103                whole_object_uses: Box::default(),
3104                has_cjs_exports: false,
3105                has_angular_component_template_url: false,
3106                content_hash: 0,
3107                suppressions: vec![Suppression::issue(0, 1, IssueKind::UnusedFile)],
3108                unknown_suppression_kinds: vec![],
3109                unused_import_bindings: vec![],
3110                type_referenced_import_bindings: vec![],
3111                value_referenced_import_bindings: vec![],
3112                line_offsets: vec![],
3113                complexity: vec![],
3114                flag_uses: vec![],
3115                class_heritage: vec![],
3116                exported_factory_returns: Box::default(),
3117                injection_tokens: vec![],
3118                local_type_declarations: Vec::new(),
3119                public_signature_type_references: Vec::new(),
3120                namespace_object_aliases: Vec::new(),
3121                iconify_prefixes: Vec::new(),
3122                iconify_icon_names: Vec::new(),
3123                auto_import_candidates: Vec::new(),
3124                directives: Vec::new(),
3125                client_only_dynamic_import_spans: Vec::new(),
3126                security_sinks: Vec::new(),
3127                security_sinks_skipped: 0,
3128                security_unresolved_callee_sites: Vec::new(),
3129                tainted_bindings: Vec::new(),
3130                sanitized_sink_args: Vec::new(),
3131                security_control_sites: Vec::new(),
3132                callee_uses: Vec::new(),
3133                misplaced_directives: Vec::new(),
3134                inline_server_action_exports: Vec::new(),
3135                di_key_sites: Vec::new(),
3136                has_dynamic_provide: false,
3137                referenced_import_bindings: Vec::new(),
3138                component_props: Vec::new(),
3139                has_props_attrs_fallthrough: false,
3140                has_define_expose: false,
3141                has_define_model: false,
3142                has_unharvestable_props: false,
3143                component_emits: Vec::new(),
3144                angular_inputs: Vec::new(),
3145                angular_outputs: Vec::new(),
3146                has_unharvestable_emits: false,
3147                has_dynamic_emit: false,
3148                has_emit_whole_object_use: false,
3149                load_return_keys: Vec::new(),
3150                has_unharvestable_load: false,
3151                has_load_data_whole_use: false,
3152                has_page_data_store_whole_use: false,
3153                component_functions: Vec::new(),
3154                react_props: Vec::new(),
3155                hook_uses: Vec::new(),
3156                render_edges: Vec::new(),
3157                svelte_dispatched_events: Vec::new(),
3158                svelte_listened_events: Vec::new(),
3159                angular_component_selectors: Vec::new(),
3160                registered_custom_elements: Vec::new(),
3161                used_custom_element_tags: Vec::new(),
3162                angular_used_selectors: Vec::new(),
3163                angular_entry_component_refs: Vec::new(),
3164                has_dynamic_component_render: false,
3165                has_dynamic_dispatch: false,
3166            }];
3167
3168            let rules = RulesConfig {
3169                unused_files: Severity::Error,
3170                ..RulesConfig::default()
3171            };
3172            let config = make_config_with_rules(rules);
3173
3174            let results = find_dead_code_full(&graph, &config, &[], None, &[], &modules, false);
3175
3176            assert!(
3177                !results.unused_files.iter().any(|f| f
3178                    .file
3179                    .path
3180                    .to_string_lossy()
3181                    .contains("utils.ts")),
3182                "suppressed file should not appear in unused_files"
3183            );
3184        }
3185    }
3186}