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