Skip to main content

fallow_core/analyze/
mod.rs

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