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