Skip to main content

fallow_engine/
dead_code.rs

1//! Dead-code result helpers exposed through the engine boundary.
2
3use std::path::{Path, PathBuf};
4
5use rustc_hash::FxHashSet;
6
7use fallow_config::ResolvedConfig;
8
9use crate::{
10    AnalysisResults, DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput,
11    DeadCodeAnalysisWithHashes, EngineResult, ModuleInfo, core_backend,
12    session::{
13        analyze_dead_code_from_config, analyze_dead_code_with_artifacts_from_config,
14        analyze_dead_code_with_complexity_from_config,
15    },
16};
17
18/// Run dead-code analysis for a resolved config.
19///
20/// # Errors
21///
22/// Returns an error if file discovery, parsing, or analysis fails.
23pub fn analyze(config: &ResolvedConfig) -> EngineResult<DeadCodeAnalysis> {
24    analyze_dead_code_with_artifacts_from_config(config, false, false).map(|output| {
25        DeadCodeAnalysis {
26            results: output.results,
27        }
28    })
29}
30
31/// Run dead-code analysis with export usage collection for a resolved config.
32///
33/// # Errors
34///
35/// Returns an error if file discovery, parsing, or analysis fails.
36pub fn analyze_with_usages(config: &ResolvedConfig) -> EngineResult<DeadCodeAnalysis> {
37    analyze_dead_code_from_config(config)
38}
39
40/// Run dead-code analysis with source hashes for drift-sensitive fixers.
41///
42/// # Errors
43///
44/// Returns an error if file discovery, parsing, or analysis fails.
45pub fn analyze_with_file_hashes(
46    config: &ResolvedConfig,
47) -> EngineResult<DeadCodeAnalysisWithHashes> {
48    analyze_dead_code_with_artifacts_from_config(config, false, false).map(|output| {
49        DeadCodeAnalysisWithHashes {
50            results: output.results,
51            file_hashes: output.file_hashes,
52        }
53    })
54}
55
56/// Run dead-code analysis with trace timings and retained graph artifacts.
57///
58/// # Errors
59///
60/// Returns an error if file discovery, parsing, or analysis fails.
61pub fn analyze_with_trace(config: &ResolvedConfig) -> EngineResult<DeadCodeAnalysisArtifacts> {
62    analyze_dead_code_with_artifacts_from_config(config, false, true)
63}
64
65/// Run dead-code analysis while retaining module and file artifacts.
66///
67/// # Errors
68///
69/// Returns an error if file discovery, parsing, or analysis fails.
70pub fn analyze_retaining_modules(
71    config: &ResolvedConfig,
72    need_complexity: bool,
73    retain_graph: bool,
74) -> EngineResult<DeadCodeAnalysisArtifacts> {
75    analyze_dead_code_with_artifacts_from_config(config, need_complexity, retain_graph)
76}
77
78/// Run dead-code analysis from pre-parsed modules.
79///
80/// # Errors
81///
82/// Returns an error if discovery, graph construction, or analysis fails.
83pub fn analyze_with_parse_result(
84    config: &ResolvedConfig,
85    modules: &[ModuleInfo],
86) -> EngineResult<DeadCodeAnalysisArtifacts> {
87    core_backend::analyze_with_parse_result(config, modules)
88}
89
90/// Run dead-code analysis with export usage and retained complexity artifacts.
91///
92/// # Errors
93///
94/// Returns an error if file discovery, parsing, or analysis fails.
95pub fn analyze_with_usages_and_complexity(
96    config: &ResolvedConfig,
97) -> EngineResult<DeadCodeAnalysisOutput> {
98    analyze_dead_code_with_complexity_from_config(config)
99}
100
101/// Scope dead-code results to the union of the given workspace roots.
102///
103/// The full cross-workspace graph is still built before this helper runs, so
104/// cross-package imports are resolved. Only reported findings are narrowed.
105pub fn filter_to_workspaces(results: &mut AnalysisResults, ws_roots: &[PathBuf]) {
106    let any_under = |path: &Path| ws_roots.iter().any(|root| path.starts_with(root));
107    let pkg_jsons = ws_roots
108        .iter()
109        .map(|root| root.join("package.json"))
110        .collect::<Vec<_>>();
111    let in_pkg_jsons = |path: &Path| pkg_jsons.iter().any(|pkg| path == pkg);
112
113    filter_workspace_source_findings(results, &any_under);
114    filter_workspace_dependency_findings(results, &any_under, &in_pkg_jsons);
115    filter_workspace_graph_findings(results, &any_under);
116    filter_workspace_policy_findings(results, &any_under);
117}
118
119/// Scope dead-code results to findings affected by changed files.
120#[expect(
121    clippy::implicit_hasher,
122    reason = "fallow standardizes on FxHashSet across the workspace"
123)]
124pub fn filter_by_changed_files(results: &mut AnalysisResults, changed_files: &FxHashSet<PathBuf>) {
125    core_backend::filter_results_by_changed_files(results, changed_files);
126}
127
128fn filter_workspace_source_findings(
129    results: &mut AnalysisResults,
130    any_under: &dyn Fn(&Path) -> bool,
131) {
132    results
133        .unused_files
134        .retain(|finding| any_under(&finding.file.path));
135    results
136        .unused_exports
137        .retain(|finding| any_under(&finding.export.path));
138    results
139        .unused_types
140        .retain(|finding| any_under(&finding.export.path));
141    results
142        .private_type_leaks
143        .retain(|finding| any_under(&finding.leak.path));
144    results
145        .unused_enum_members
146        .retain(|finding| any_under(&finding.member.path));
147    results
148        .unused_class_members
149        .retain(|finding| any_under(&finding.member.path));
150    results
151        .unused_store_members
152        .retain(|finding| any_under(&finding.member.path));
153    results
154        .unprovided_injects
155        .retain(|finding| any_under(&finding.inject.path));
156    results
157        .unrendered_components
158        .retain(|finding| any_under(&finding.component.path));
159    results
160        .unused_component_props
161        .retain(|finding| any_under(&finding.prop.path));
162    results
163        .unused_component_emits
164        .retain(|finding| any_under(&finding.emit.path));
165    results
166        .unused_component_inputs
167        .retain(|finding| any_under(&finding.input.path));
168    results
169        .unused_component_outputs
170        .retain(|finding| any_under(&finding.output.path));
171    results
172        .unused_svelte_events
173        .retain(|finding| any_under(&finding.event.path));
174    results
175        .unused_server_actions
176        .retain(|finding| any_under(&finding.action.path));
177    results
178        .unused_load_data_keys
179        .retain(|finding| any_under(&finding.key.path));
180    results
181        .unresolved_imports
182        .retain(|finding| any_under(&finding.import.path));
183}
184
185fn filter_workspace_dependency_findings(
186    results: &mut AnalysisResults,
187    any_under: &dyn Fn(&Path) -> bool,
188    in_pkg_jsons: &dyn Fn(&Path) -> bool,
189) {
190    results
191        .unused_dependencies
192        .retain(|finding| in_pkg_jsons(&finding.dep.path));
193    results
194        .unused_dev_dependencies
195        .retain(|finding| in_pkg_jsons(&finding.dep.path));
196    results
197        .unused_optional_dependencies
198        .retain(|finding| in_pkg_jsons(&finding.dep.path));
199    results
200        .type_only_dependencies
201        .retain(|finding| in_pkg_jsons(&finding.dep.path));
202    results
203        .test_only_dependencies
204        .retain(|finding| in_pkg_jsons(&finding.dep.path));
205
206    results.unlisted_dependencies.retain(|finding| {
207        finding
208            .dep
209            .imported_from
210            .iter()
211            .any(|source| any_under(&source.path))
212    });
213    results.unused_dependency_overrides.clear();
214    results.misconfigured_dependency_overrides.clear();
215}
216
217fn filter_workspace_graph_findings(
218    results: &mut AnalysisResults,
219    any_under: &dyn Fn(&Path) -> bool,
220) {
221    for duplicate in &mut results.duplicate_exports {
222        duplicate
223            .export
224            .locations
225            .retain(|location| any_under(&location.path));
226    }
227    results
228        .duplicate_exports
229        .retain(|duplicate| duplicate.export.locations.len() >= 2);
230
231    results
232        .circular_dependencies
233        .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
234
235    results
236        .re_export_cycles
237        .retain(|cycle| cycle.cycle.files.iter().any(|path| any_under(path)));
238}
239
240fn filter_workspace_policy_findings(
241    results: &mut AnalysisResults,
242    any_under: &dyn Fn(&Path) -> bool,
243) {
244    results
245        .boundary_violations
246        .retain(|finding| any_under(&finding.violation.from_path));
247    results
248        .boundary_coverage_violations
249        .retain(|finding| any_under(&finding.violation.path));
250    results
251        .boundary_call_violations
252        .retain(|finding| any_under(&finding.violation.path));
253    results
254        .policy_violations
255        .retain(|finding| any_under(&finding.violation.path));
256
257    results
258        .stale_suppressions
259        .retain(|finding| any_under(&finding.path));
260
261    results
262        .security_findings
263        .retain(|finding| any_under(&finding.path));
264    results
265        .security_unresolved_callee_diagnostics
266        .retain(|finding| any_under(&finding.path));
267
268    results.unused_catalog_entries.clear();
269    results.empty_catalog_groups.clear();
270    results
271        .unresolved_catalog_references
272        .retain(|finding| any_under(&finding.reference.path));
273
274    results
275        .invalid_client_exports
276        .retain(|finding| any_under(&finding.export.path));
277
278    results
279        .mixed_client_server_barrels
280        .retain(|finding| any_under(&finding.barrel.path));
281
282    results
283        .misplaced_directives
284        .retain(|finding| any_under(&finding.directive_site.path));
285
286    results
287        .route_collisions
288        .retain(|finding| any_under(&finding.collision.path));
289
290    results
291        .dynamic_segment_name_conflicts
292        .retain(|finding| any_under(&finding.conflict.path));
293}
294
295#[cfg(test)]
296mod tests {
297    use std::path::PathBuf;
298
299    use super::*;
300    use fallow_types::output_dead_code::UnusedFileFinding;
301    use fallow_types::results::UnusedFile;
302
303    #[test]
304    fn workspace_filter_keeps_findings_under_workspace_root() {
305        let root = PathBuf::from("/repo/packages/app");
306        let mut results = AnalysisResults::default();
307        results
308            .unused_files
309            .push(UnusedFileFinding::with_actions(UnusedFile {
310                path: root.join("src/unused.ts"),
311            }));
312        results
313            .unused_files
314            .push(UnusedFileFinding::with_actions(UnusedFile {
315                path: PathBuf::from("/repo/packages/docs/src/unused.ts"),
316            }));
317
318        filter_to_workspaces(&mut results, std::slice::from_ref(&root));
319
320        assert_eq!(results.unused_files.len(), 1);
321        assert_eq!(
322            results.unused_files[0].file.path,
323            root.join("src/unused.ts")
324        );
325    }
326}