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}
47
48/// Keep only the clone groups with at least one instance whose line range
49/// overlaps an added line of the diff.
50///
51/// The filter keeps the whole group when one instance overlaps, so a reviewer
52/// sees the full clone family in the context of the change. Clone families,
53/// statistics and the order are rebuilt from the groups that stay, so the
54/// duplication percentage describes the scoped slice.
55pub fn filter_duplication_by_diff(report: &mut DuplicationReport, diff: &DiffIndex, root: &Path) {
56    let instance_overlaps = |instance: &CloneInstance| -> bool {
57        let Some(rel) = diff.key_for(&instance.file, root) else {
58            return true;
59        };
60        let start = u64::try_from(instance.start_line).unwrap_or(u64::MAX);
61        let end = u64::try_from(instance.end_line).unwrap_or(u64::MAX);
62        diff.range_overlaps_added(&rel, start, end)
63    };
64    report
65        .clone_groups
66        .retain(|group| group.instances.iter().any(instance_overlaps));
67    crate::duplicates::refresh_scoped_report(report, root);
68}
69
70fn filter_source_findings(
71    results: &mut AnalysisResults,
72    touches_file: &dyn Fn(&Path) -> bool,
73    line_in_diff: &dyn Fn(&Path, u32) -> bool,
74) {
75    results
76        .unused_files
77        .retain(|finding| touches_file(&finding.file.path));
78    results
79        .unused_exports
80        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
81    results
82        .unused_types
83        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
84    results
85        .private_type_leaks
86        .retain(|finding| line_in_diff(&finding.leak.path, finding.leak.line));
87    results
88        .deprecated_exports_in_use
89        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
90    results
91        .unused_enum_members
92        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
93    results
94        .unused_class_members
95        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
96    results
97        .unused_store_members
98        .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
99    results
100        .unprovided_injects
101        .retain(|finding| line_in_diff(&finding.inject.path, finding.inject.line));
102    results
103        .unrendered_components
104        .retain(|finding| line_in_diff(&finding.component.path, finding.component.line));
105    results
106        .unused_component_props
107        .retain(|finding| line_in_diff(&finding.prop.path, finding.prop.line));
108    results
109        .unused_component_emits
110        .retain(|finding| line_in_diff(&finding.emit.path, finding.emit.line));
111    results
112        .unused_component_inputs
113        .retain(|finding| line_in_diff(&finding.input.path, finding.input.line));
114    results
115        .unused_component_outputs
116        .retain(|finding| line_in_diff(&finding.output.path, finding.output.line));
117    results
118        .unused_svelte_events
119        .retain(|finding| line_in_diff(&finding.event.path, finding.event.line));
120    results
121        .unused_server_actions
122        .retain(|finding| line_in_diff(&finding.action.path, finding.action.line));
123    results
124        .unused_load_data_keys
125        .retain(|finding| line_in_diff(&finding.key.path, finding.key.line));
126    results
127        .unresolved_imports
128        .retain(|finding| line_in_diff(&finding.import.path, finding.import.line));
129}
130
131fn filter_security_findings(
132    results: &mut AnalysisResults,
133    touches_file: &dyn Fn(&Path) -> bool,
134    line_in_diff: &dyn Fn(&Path, u32) -> bool,
135) {
136    results.security_findings.retain(|finding| {
137        line_in_diff(&finding.path, finding.line)
138            || finding.trace.iter().any(|hop| {
139                line_in_diff(&hop.path, hop.line)
140                    || (matches!(hop.role, TraceHopRole::SecretSource) && touches_file(&hop.path))
141            })
142            || finding.reachability.as_ref().is_some_and(|reachability| {
143                // Any hop on an added line keeps the finding for display, whatever
144                // its role. Do not add a role check here: unlike the strict
145                // `--gate new` filter of `fallow security`, the display must show a
146                // finding whose module-level source sits on an added line.
147                reachability
148                    .untrusted_source_trace
149                    .iter()
150                    .any(|hop| line_in_diff(&hop.path, hop.line))
151            })
152    });
153    results
154        .security_unresolved_callee_diagnostics
155        .retain(|finding| line_in_diff(&finding.path, finding.line));
156}
157
158fn filter_dependency_findings(
159    results: &mut AnalysisResults,
160    line_in_diff: &dyn Fn(&Path, u32) -> bool,
161) {
162    for finding in &mut results.unlisted_dependencies {
163        finding
164            .dep
165            .imported_from
166            .retain(|source| line_in_diff(&source.path, source.line));
167    }
168    results
169        .unlisted_dependencies
170        .retain(|finding| !finding.dep.imported_from.is_empty());
171}
172
173fn filter_graph_findings(
174    results: &mut AnalysisResults,
175    touches_file: &dyn Fn(&Path) -> bool,
176    line_in_diff: &dyn Fn(&Path, u32) -> bool,
177) {
178    results.duplicate_exports.retain(|finding| {
179        finding
180            .export
181            .locations
182            .iter()
183            .any(|location| line_in_diff(&location.path, location.line))
184    });
185    results
186        .circular_dependencies
187        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
188    results
189        .re_export_cycles
190        .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
191    results
192        .boundary_violations
193        .retain(|finding| line_in_diff(&finding.violation.from_path, finding.violation.line));
194    results
195        .stale_suppressions
196        .retain(|finding| line_in_diff(&finding.path, finding.line));
197}
198
199fn filter_framework_findings(
200    results: &mut AnalysisResults,
201    line_in_diff: &dyn Fn(&Path, u32) -> bool,
202) {
203    results
204        .invalid_client_exports
205        .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
206    results
207        .mixed_client_server_barrels
208        .retain(|finding| line_in_diff(&finding.barrel.path, finding.barrel.line));
209    results
210        .misplaced_directives
211        .retain(|finding| line_in_diff(&finding.directive_site.path, finding.directive_site.line));
212    results
213        .route_collisions
214        .retain(|finding| line_in_diff(&finding.collision.path, finding.collision.line));
215    results
216        .dynamic_segment_name_conflicts
217        .retain(|finding| line_in_diff(&finding.conflict.path, finding.conflict.line));
218}