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