fallow_engine/
dead_code.rs1use 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
19pub(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
31pub 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#[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
58pub 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}