Skip to main content

fallow_engine/
diff_scope.rs

1//! Diff line filters shared by every surface that accepts a unified diff.
2//!
3//! The CLI (`--diff-file`, `--diff-stdin` and the CI diff) and the programmatic
4//! API (`analysis.diffFile`) narrow a report to the lines a change added. Both
5//! call the functions in this module, so a finding cannot pass the filter on
6//! one surface and fail it on another.
7
8use std::path::Path;
9
10use fallow_output::DiffIndex;
11use fallow_types::duplicates::{CloneInstance, DuplicationReport};
12use fallow_types::results::{AnalysisResults, TraceHopRole};
13
14/// Drop the dead-code findings whose source line is not on an added line of
15/// the diff.
16///
17/// Range-shaped findings (clone instances, complexity hotspots) have their own
18/// filters. This filter governs the per-file findings on [`AnalysisResults`].
19///
20/// **Project-level findings bypass the filter.** A change that deletes the
21/// last consumer of a package causes the `unused-dependency` finding, even
22/// when the diff does not touch `package.json`. The same holds for catalog
23/// entries, dependency overrides, type-only dependencies and test-only
24/// dependencies. The line filter reduces noise for source-anchored findings.
25/// CI must still fail on the project-level findings that the change caused.
26///
27/// [`DiffIndex::key_for`] gives the diff key of a finding path, relative to the
28/// base of the diff and not to `root`. When a path has no key (a different
29/// drive, or a path outside the base), the finding stays: a path that the
30/// filter cannot judge is better shown than hidden.
31pub fn filter_dead_code_by_diff(results: &mut AnalysisResults, diff: &DiffIndex, root: &Path) {
32    let touches_file = |path: &Path| -> bool {
33        diff.key_for(path, root)
34            .is_none_or(|rel| diff.touches_file(&rel))
35    };
36    let line_in_diff = |path: &Path, line: u32| -> bool {
37        diff.key_for(path, root)
38            .is_none_or(|rel| diff.line_is_added(&rel, u64::from(line)))
39    };
40
41    filter_source_findings(results, &touches_file, &line_in_diff);
42    filter_security_findings(results, &touches_file, &line_in_diff);
43    filter_dependency_findings(results, &line_in_diff);
44    filter_graph_findings(results, &touches_file, &line_in_diff);
45    filter_framework_findings(results, &line_in_diff);
46    results.prop_drilling_chains.retain(|finding| {
47        finding.effective_severity != Some(fallow_types::output_dead_code::EffectiveSeverity::Error)
48            || finding.chain.hops.is_empty()
49            || finding
50                .chain
51                .hops
52                .iter()
53                .any(|hop| line_in_diff(&hop.file, hop.line))
54    });
55}
56
57/// Keep only the clone groups with at least one instance whose line range
58/// overlaps an added line of the diff.
59///
60/// The filter keeps the whole group when one instance overlaps, so a reviewer
61/// sees the full clone family in the context of the change. Clone families,
62/// statistics and the order are rebuilt from the groups that stay, so the
63/// duplication percentage describes the scoped slice.
64pub fn filter_duplication_by_diff(report: &mut DuplicationReport, diff: &DiffIndex, root: &Path) {
65    let instance_overlaps = |instance: &CloneInstance| -> bool {
66        let Some(rel) = diff.key_for(&instance.file, root) else {
67            return true;
68        };
69        let start = u64::try_from(instance.start_line).unwrap_or(u64::MAX);
70        let end = u64::try_from(instance.end_line).unwrap_or(u64::MAX);
71        diff.range_overlaps_added(&rel, start, end)
72    };
73    report
74        .clone_groups
75        .retain(|group| group.instances.iter().any(instance_overlaps));
76    crate::duplicates::refresh_scoped_report(report, root);
77}
78
79fn filter_source_findings(
80    results: &mut AnalysisResults,
81    touches_file: &dyn Fn(&Path) -> bool,
82    line_in_diff: &dyn Fn(&Path, u32) -> bool,
83) {
84    results
85        .unused_files
86        .retain(|finding| touches_file(&finding.file.path));
87    results
88        .unused_exports
89        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
90    results
91        .unused_types
92        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
93    results
94        .private_type_leaks
95        .retain(|finding| line_in_diff(&finding.leak.path, finding.leak.line));
96    results
97        .deprecated_exports_in_use
98        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
99    results
100        .unused_enum_members
101        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
102    results
103        .unused_class_members
104        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
105    results
106        .unused_store_members
107        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
108    results
109        .unprovided_injects
110        .retain(|finding| line_in_diff(&finding.inject.path, finding.inject.line));
111    results
112        .unrendered_components
113        .retain(|finding| line_in_diff(&finding.component.path, finding.component.line));
114    results
115        .unused_component_props
116        .retain(|finding| line_in_diff(&finding.prop.path, finding.prop.line));
117    results
118        .absent_component_props
119        .retain(|finding| line_in_diff(&finding.prop.path, finding.prop.line));
120    results
121        .unused_component_emits
122        .retain(|finding| line_in_diff(&finding.emit.path, finding.emit.line));
123    results
124        .unused_component_inputs
125        .retain(|finding| line_in_diff(&finding.input.path, finding.input.line));
126    results
127        .unused_component_outputs
128        .retain(|finding| line_in_diff(&finding.output.path, finding.output.line));
129    results
130        .unused_svelte_events
131        .retain(|finding| line_in_diff(&finding.event.path, finding.event.line));
132    results
133        .unused_effect_layers
134        .retain(|finding| line_in_diff(&finding.layer.path, finding.layer.line));
135    results
136        .unused_tagged_errors
137        .retain(|finding| line_in_diff(&finding.error.path, finding.error.line));
138    results
139        .unproduced_catch_tags
140        .retain(|finding| line_in_diff(&finding.catch_tag.path, finding.catch_tag.line));
141    results
142        .unused_server_actions
143        .retain(|finding| line_in_diff(&finding.action.path, finding.action.line));
144    results
145        .unused_load_data_keys
146        .retain(|finding| line_in_diff(&finding.key.path, finding.key.line));
147    results
148        .unresolved_imports
149        .retain(|finding| line_in_diff(&finding.import.path, finding.import.line));
150}
151
152fn filter_security_findings(
153    results: &mut AnalysisResults,
154    touches_file: &dyn Fn(&Path) -> bool,
155    line_in_diff: &dyn Fn(&Path, u32) -> bool,
156) {
157    results.security_findings.retain(|finding| {
158        line_in_diff(&finding.path, finding.line)
159            || finding.trace.iter().any(|hop| {
160                line_in_diff(&hop.path, hop.line)
161                    || (matches!(hop.role, TraceHopRole::SecretSource) && touches_file(&hop.path))
162            })
163            || finding.reachability.as_ref().is_some_and(|reachability| {
164                // Any hop on an added line keeps the finding for display, whatever
165                // its role. Do not add a role check here: unlike the strict
166                // `--gate new` filter of `fallow security`, the display must show a
167                // finding whose module-level source sits on an added line.
168                reachability
169                    .untrusted_source_trace
170                    .iter()
171                    .any(|hop| line_in_diff(&hop.path, hop.line))
172            })
173    });
174    results
175        .security_unresolved_callee_diagnostics
176        .retain(|finding| line_in_diff(&finding.path, finding.line));
177}
178
179fn filter_dependency_findings(
180    results: &mut AnalysisResults,
181    line_in_diff: &dyn Fn(&Path, u32) -> bool,
182) {
183    for finding in &mut results.unlisted_dependencies {
184        finding
185            .dep
186            .imported_from
187            .retain(|source| line_in_diff(&source.path, source.line));
188    }
189    results
190        .unlisted_dependencies
191        .retain(|finding| !finding.dep.imported_from.is_empty());
192}
193
194fn filter_graph_findings(
195    results: &mut AnalysisResults,
196    touches_file: &dyn Fn(&Path) -> bool,
197    line_in_diff: &dyn Fn(&Path, u32) -> bool,
198) {
199    results.duplicate_exports.retain(|finding| {
200        finding
201            .export
202            .locations
203            .iter()
204            .any(|location| line_in_diff(&location.path, location.line))
205    });
206    results
207        .circular_dependencies
208        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
209    results
210        .re_export_cycles
211        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
212    results.package_cycles.retain(|cycle| {
213        cycle
214            .cycle
215            .edges
216            .iter()
217            .any(|edge| touches_file(&edge.path))
218    });
219    results
220        .boundary_violations
221        .retain(|finding| line_in_diff(&finding.violation.from_path, finding.violation.line));
222    results
223        .stale_suppressions
224        .retain(|finding| line_in_diff(&finding.path, finding.line));
225}
226
227fn filter_framework_findings(
228    results: &mut AnalysisResults,
229    line_in_diff: &dyn Fn(&Path, u32) -> bool,
230) {
231    results
232        .invalid_client_exports
233        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
234    results
235        .mixed_client_server_barrels
236        .retain(|finding| line_in_diff(&finding.barrel.path, finding.barrel.line));
237    results
238        .misplaced_directives
239        .retain(|finding| line_in_diff(&finding.directive_site.path, finding.directive_site.line));
240    results
241        .route_collisions
242        .retain(|finding| line_in_diff(&finding.collision.path, finding.collision.line));
243    results
244        .dynamic_segment_name_conflicts
245        .retain(|finding| line_in_diff(&finding.conflict.path, finding.conflict.line));
246}