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