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