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