Skip to main content

fallow_api/audit_run/
outcome.rs

1//! Comparison of the head run with the base snapshot, and the verdict.
2
3use std::path::Path;
4
5use fallow_config::{AuditGate, RulesConfig, Severity};
6
7use super::{AuditAnalysesView, AuditKeySnapshot};
8use crate::audit_keys::{
9    AuditComparison, AuditDomainLedger, DeadCodeAuditLedger, dead_code_audit_ledger,
10    dupe_group_key, health_finding_key, preexisting_dupe_group_keys, styling_finding_key,
11};
12use crate::{AuditAttribution, AuditSummary, AuditVerdict};
13
14/// Which diff decided the new-only duplication demotion, so output can name
15/// where a demotion came from (issue #2220).
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum DupeDemotionDiffSource {
18    /// The opt-in shared diff index decided. Carries the user-facing source
19    /// label (`--diff-file <path>`, `--diff-stdin`, or
20    /// `$FALLOW_DIFF_FILE <path>`).
21    Shared(String),
22    /// The merge-base worktree diff against the resolved base ref decided.
23    Worktree,
24    /// No diff was available. The demotion check did not run, and every
25    /// introduced clone group kept its gate.
26    Skipped,
27}
28
29impl DupeDemotionDiffSource {
30    /// User-facing label for the diff that decided the demotion.
31    #[must_use]
32    pub fn label(&self, base_ref: &str) -> String {
33        match self {
34            Self::Shared(label) => label.clone(),
35            Self::Worktree => format!("merge-base worktree diff vs {base_ref}"),
36            Self::Skipped => "skipped: no diff available".to_string(),
37        }
38    }
39}
40
41/// An opt-in shared diff that the run already applied, with its source label.
42#[derive(Clone, Copy)]
43pub struct SharedDiff<'a> {
44    /// The parsed diff.
45    pub index: &'a fallow_output::DiffIndex,
46    /// The user-facing source label, for example `--diff-file <path>`.
47    pub label: &'a str,
48}
49
50/// Classify every head finding once against the base snapshot.
51///
52/// With `syntactic_fallback` set (the degraded type-aware path), dead code
53/// compares against the syntactic keys of the base, and a head finding that
54/// only semantic evidence produced stays advisory: it has no syntactic base
55/// counterpart to compare with.
56#[must_use]
57pub fn compare(
58    view: &AuditAnalysesView<'_>,
59    base: Option<&AuditKeySnapshot>,
60    syntactic_fallback: bool,
61) -> AuditComparison {
62    let dead_code =
63        view.dead_code
64            .as_ref()
65            .map_or_else(DeadCodeAuditLedger::default, |dead_code| {
66                // A base that ran without type-aware analysis is already
67                // syntactic, so its refined set is also its fallback set.
68                let base_keys = base.map(|snapshot| {
69                    if syntactic_fallback {
70                        snapshot
71                            .syntactic_dead_code
72                            .as_ref()
73                            .unwrap_or(&snapshot.dead_code)
74                    } else {
75                        &snapshot.dead_code
76                    }
77                });
78                let mut ledger = dead_code_audit_ledger(
79                    dead_code.results,
80                    dead_code.root,
81                    dead_code.config,
82                    base_keys,
83                );
84                if syntactic_fallback && let Some(head_syntactic) = dead_code.syntactic_keys {
85                    ledger.demote_unattributable_introductions(head_syntactic);
86                }
87                ledger
88            });
89    let health = AuditDomainLedger::compare(
90        view.health.iter().flat_map(|health| {
91            health
92                .report
93                .findings
94                .iter()
95                .map(|finding| health_finding_key(finding, health.root))
96        }),
97        base.map(|snapshot| &snapshot.health),
98    );
99    let dupes = AuditDomainLedger::compare(
100        view.duplication.iter().flat_map(|duplication| {
101            duplication
102                .clone_groups
103                .iter()
104                .map(|group| dupe_group_key(group, duplication.root))
105        }),
106        base.map(|snapshot| &snapshot.dupes),
107    );
108    let styling = AuditDomainLedger::compare(
109        view.health.iter().flat_map(|health| {
110            health
111                .report
112                .styling_findings
113                .iter()
114                .map(|finding| styling_finding_key(finding, health.root))
115        }),
116        base.map(|snapshot| &snapshot.styling),
117    );
118    AuditComparison {
119        dead_code,
120        health,
121        dupes,
122        styling,
123    }
124}
125
126/// Demote introduced clone groups that hold no added line of the run's diff.
127///
128/// No instance range holds an added line, so the changeset did not write the
129/// duplicated text. Only the attribution key of the group changed, because the
130/// changeset removed code in another place. Without this step, a clone-removal
131/// refactor fails the new-only gate on duplication that it did not write
132/// (issue #2164). The opt-in shared diff decides when it is present; the
133/// merge-base worktree diff decides in the other cases.
134pub fn demote_preexisting_dupe_introductions(
135    comparison: &mut AuditComparison,
136    view: &AuditAnalysesView<'_>,
137    root: &Path,
138    base_ref: &str,
139    shared: Option<SharedDiff<'_>>,
140) -> Option<DupeDemotionDiffSource> {
141    if comparison.dupes.introduced_count() == 0 {
142        return None;
143    }
144    let duplication = view.duplication.as_ref()?;
145    let worktree_index;
146    let (index, source) = if let Some(shared) = shared {
147        (
148            shared.index,
149            DupeDemotionDiffSource::Shared(shared.label.to_owned()),
150        )
151    } else if let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) {
152        worktree_index = fallow_output::DiffIndex::from_unified_diff(&diff);
153        (&worktree_index, DupeDemotionDiffSource::Worktree)
154    } else {
155        return Some(DupeDemotionDiffSource::Skipped);
156    };
157    let demote = preexisting_dupe_group_keys(
158        duplication.clone_groups.iter().copied(),
159        duplication.root,
160        index,
161    );
162    comparison.dupes.demote_introductions(&demote);
163    Some(source)
164}
165
166/// The severity of the rule that owns a styling finding `code`. Styling is
167/// verdict-neutral by default (rule `warn`).
168#[must_use]
169pub fn styling_rule_severity(rules: &RulesConfig, code: &str) -> Severity {
170    match code {
171        "css-token-drift" => rules.css_token_drift,
172        "css-duplicate-block" => rules.css_duplicate_block,
173        "css-selector-complexity" => rules.css_selector_complexity,
174        "css-dead-surface" => rules.css_dead_surface,
175        "css-broken-reference" => rules.css_broken_reference,
176        _ => Severity::Warn,
177    }
178}
179
180/// Whether a styling finding escalates to `error` and so gates the verdict.
181#[must_use]
182pub fn styling_finding_gates(rules: &RulesConfig, code: &str) -> bool {
183    styling_rule_severity(rules, code) == Severity::Error
184}
185
186/// Attribution counts, verdict and summary of one comparison.
187#[must_use]
188pub fn outcome(
189    gate: AuditGate,
190    view: &AuditAnalysesView<'_>,
191    comparison: &AuditComparison,
192    has_base: bool,
193) -> (AuditAttribution, AuditVerdict, AuditSummary) {
194    let summary = summary(view, comparison);
195    (
196        attribution(gate, comparison, has_base),
197        verdict(gate, view, comparison, &summary),
198        summary,
199    )
200}
201
202fn verdict(
203    gate: AuditGate,
204    view: &AuditAnalysesView<'_>,
205    comparison: &AuditComparison,
206    summary: &AuditSummary,
207) -> AuditVerdict {
208    let new_only = matches!(gate, AuditGate::NewOnly);
209    let dead_code_errors = if new_only {
210        comparison.dead_code.has_introduced_errors()
211    } else {
212        dead_code_has_errors(view)
213    };
214    let dead_code_warnings = if new_only {
215        comparison.dead_code.has_introduced_warnings()
216    } else {
217        comparison
218            .dead_code
219            .records()
220            .iter()
221            .any(|record| record.effective_severity == Severity::Warn)
222    };
223    // The `complexity-*` rules decide if a finding blocks: `error` fails the
224    // verdict and `warn` gives `warn`. The `new-only` gate reads only the
225    // introduced findings.
226    let (complexity_errors, complexity_warnings) =
227        view.health.as_ref().map_or((false, false), |health| {
228            health
229                .report
230                .findings
231                .iter()
232                .zip(comparison.health.introduced())
233                .filter(|(_, introduced)| !new_only || *introduced)
234                .fold((false, false), |(errors, warnings), (finding, _)| {
235                    if finding.blocks() {
236                        (true, warnings)
237                    } else {
238                        (errors, true)
239                    }
240                })
241        });
242    let styling_errors = view.health.as_ref().is_some_and(|health| {
243        health
244            .report
245            .styling_findings
246            .iter()
247            .zip(comparison.styling.introduced())
248            .any(|(finding, introduced)| {
249                (!new_only || introduced) && styling_finding_gates(health.rules, &finding.code)
250            })
251    });
252    let duplication_findings = if new_only {
253        comparison.dupes.introduced_count()
254    } else {
255        summary.duplication_clone_groups
256    };
257    let duplication_errors = view.duplication.as_ref().is_some_and(|duplication| {
258        duplication_findings > 0
259            && duplication.threshold > 0.0
260            && duplication.duplication_percentage > duplication.threshold
261    });
262    if dead_code_errors || complexity_errors || styling_errors || duplication_errors {
263        AuditVerdict::Fail
264    } else if dead_code_warnings || complexity_warnings || duplication_findings > 0 {
265        AuditVerdict::Warn
266    } else {
267        AuditVerdict::Pass
268    }
269}
270
271/// Whether the head dead-code findings hold an error-severity finding, by the
272/// same rule that decides the exit code of `fallow dead-code`.
273fn dead_code_has_errors(view: &AuditAnalysesView<'_>) -> bool {
274    view.dead_code.as_ref().is_some_and(|dead_code| {
275        fallow_engine::error_severity::has_error_severity_issues(
276            dead_code.results,
277            &dead_code.config.rules,
278            Some(dead_code.config),
279            false,
280        )
281    })
282}
283
284fn attribution(gate: AuditGate, comparison: &AuditComparison, has_base: bool) -> AuditAttribution {
285    if !has_base {
286        return AuditAttribution {
287            gate,
288            ..AuditAttribution::default()
289        };
290    }
291    AuditAttribution {
292        gate,
293        dead_code_introduced: comparison.dead_code.introduced_count(),
294        dead_code_inherited: comparison.dead_code.inherited_count(),
295        complexity_introduced: comparison.health.introduced_count(),
296        complexity_inherited: comparison.health.inherited_count(),
297        duplication_introduced: comparison.dupes.introduced_count(),
298        duplication_inherited: comparison.dupes.inherited_count(),
299    }
300}
301
302fn summary(view: &AuditAnalysesView<'_>, comparison: &AuditComparison) -> AuditSummary {
303    AuditSummary {
304        dead_code_issues: comparison.dead_code.visible_count(),
305        dead_code_has_errors: dead_code_has_errors(view),
306        complexity_findings: view
307            .health
308            .as_ref()
309            .map_or(0, |health| health.report.findings.len()),
310        max_cyclomatic: view.health.as_ref().and_then(|health| {
311            health
312                .report
313                .findings
314                .iter()
315                .map(|finding| finding.cyclomatic)
316                .max()
317        }),
318        duplication_clone_groups: view
319            .duplication
320            .as_ref()
321            .map_or(0, |duplication| duplication.clone_groups.len()),
322    }
323}