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, 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
20pub(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
32pub 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#[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
59pub 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}