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