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