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, AuditWorsened, RulesConfig, Severity};
6
7use super::{AuditAnalysesView, AuditKeySnapshot};
8use crate::audit_keys::{
9    AuditComparison, AuditDomainLedger, DeadCodeAuditLedger,
10    classify_complexity_attribution_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_classes =
91        view.health
92            .as_ref()
93            .zip(base)
94            .map_or_else(Vec::new, |(health, snapshot)| {
95                classify_complexity_attribution_against_all(
96                    &health.report.findings,
97                    health.root,
98                    std::iter::once(&snapshot.health).chain(&snapshot.inherited_health),
99                )
100            });
101    let health = view
102        .health
103        .as_ref()
104        .map_or_else(AuditDomainLedger::default, |health| {
105            let findings = &health.report.findings;
106            let keys = complexity_ledger_keys(findings, health.root);
107            let introduced = (0..findings.len()).map(|index| {
108                health_classes
109                    .get(index)
110                    .is_some_and(|class| class.introduced)
111            });
112            AuditDomainLedger::from_classified(keys.into_iter().zip(introduced), base.is_some())
113        });
114    let dupes = AuditDomainLedger::compare(
115        view.duplication.iter().flat_map(|duplication| {
116            duplication
117                .clone_groups
118                .iter()
119                .map(|group| dupe_group_key(group, duplication.root))
120        }),
121        base.map(|snapshot| &snapshot.dupes),
122    );
123    let styling = AuditDomainLedger::compare(
124        view.health.iter().flat_map(|health| {
125            health
126                .report
127                .styling_findings
128                .iter()
129                .map(|finding| styling_finding_key(finding, health.root))
130        }),
131        base.map(|snapshot| &snapshot.styling),
132    );
133    AuditComparison {
134        dead_code,
135        health,
136        health_classes,
137        dupes,
138        styling,
139    }
140}
141
142/// Demote introduced clone groups that hold no added line of the run's diff.
143///
144/// No instance range holds an added line, so the changeset did not write the
145/// duplicated text. Only the attribution key of the group changed, because the
146/// changeset removed code in another place. Without this step, a clone-removal
147/// refactor fails the new-only gate on duplication that it did not write
148/// (issue #2164). The opt-in shared diff decides when it is present; the
149/// merge-base worktree diff decides in the other cases.
150pub fn demote_preexisting_dupe_introductions(
151    comparison: &mut AuditComparison,
152    view: &AuditAnalysesView<'_>,
153    root: &Path,
154    base_ref: &str,
155    shared: Option<SharedDiff<'_>>,
156) -> Option<DupeDemotionDiffSource> {
157    if comparison.dupes.introduced_count() == 0 {
158        return None;
159    }
160    let duplication = view.duplication.as_ref()?;
161    let worktree_index;
162    let (index, source) = if let Some(shared) = shared {
163        (
164            shared.index,
165            DupeDemotionDiffSource::Shared(shared.label.to_owned()),
166        )
167    } else if let Ok(diff) = fallow_engine::changed_files::try_get_changed_diff(root, base_ref) {
168        worktree_index = fallow_output::DiffIndex::from_unified_diff(&diff);
169        (&worktree_index, DupeDemotionDiffSource::Worktree)
170    } else {
171        return Some(DupeDemotionDiffSource::Skipped);
172    };
173    let demote = preexisting_dupe_group_keys(
174        duplication.clone_groups.iter().copied(),
175        duplication.root,
176        index,
177    );
178    comparison.dupes.demote_introductions(&demote);
179    Some(source)
180}
181
182/// The severity of the rule that owns a styling finding `code`. Styling is
183/// verdict-neutral by default (rule `warn`).
184#[must_use]
185pub fn styling_rule_severity(rules: &RulesConfig, code: &str) -> Severity {
186    match code {
187        "css-token-drift" => rules.css_token_drift,
188        "css-duplicate-block" => rules.css_duplicate_block,
189        "css-selector-complexity" => rules.css_selector_complexity,
190        "css-dead-surface" => rules.css_dead_surface,
191        "css-broken-reference" => rules.css_broken_reference,
192        _ => Severity::Warn,
193    }
194}
195
196/// Whether a styling finding escalates to `error` and so gates the verdict.
197#[must_use]
198pub fn styling_finding_gates(rules: &RulesConfig, code: &str) -> bool {
199    styling_rule_severity(rules, code) == Severity::Error
200}
201
202/// Attribution counts, verdict and summary of one comparison.
203#[must_use]
204pub fn outcome(
205    gate: AuditGate,
206    worsened: AuditWorsened,
207    view: &AuditAnalysesView<'_>,
208    comparison: &AuditComparison,
209    has_base: bool,
210) -> (AuditAttribution, AuditVerdict, AuditSummary) {
211    let summary = summary(view, comparison);
212    (
213        attribution(gate, worsened, comparison, has_base),
214        verdict(gate, worsened, view, comparison, &summary),
215        summary,
216    )
217}
218
219/// How each complexity finding takes part in the verdict: `(gates, warns)`.
220///
221/// Under `new-only`, an introduced finding gates. A worsened finding gates
222/// under `audit.worsened: error`, and gives at least `warn` under `warn`.
223fn complexity_verdict_flags(
224    gate: AuditGate,
225    worsened: AuditWorsened,
226    view: &AuditAnalysesView<'_>,
227    comparison: &AuditComparison,
228) -> (bool, bool) {
229    let new_only = matches!(gate, AuditGate::NewOnly);
230    let Some(health) = view.health.as_ref() else {
231        return (false, false);
232    };
233    let mut errors = false;
234    let mut warnings = false;
235    for (index, (finding, introduced)) in health
236        .report
237        .findings
238        .iter()
239        .zip(comparison.health.introduced())
240        .enumerate()
241    {
242        let is_worsened = comparison
243            .health_classes
244            .get(index)
245            .is_some_and(crate::audit_keys::ComplexityClass::is_worsened);
246        let counts = !new_only || introduced || (is_worsened && worsened == AuditWorsened::Error);
247        if counts {
248            if finding.blocks() {
249                errors = true;
250            } else {
251                warnings = true;
252            }
253        } else if is_worsened {
254            warnings = true;
255        }
256    }
257    (errors, warnings)
258}
259
260fn verdict(
261    gate: AuditGate,
262    worsened: AuditWorsened,
263    view: &AuditAnalysesView<'_>,
264    comparison: &AuditComparison,
265    summary: &AuditSummary,
266) -> AuditVerdict {
267    let new_only = matches!(gate, AuditGate::NewOnly);
268    let dead_code_errors = if new_only {
269        comparison.dead_code.has_introduced_errors()
270    } else {
271        dead_code_has_errors(view)
272    };
273    let dead_code_warnings = if new_only {
274        comparison.dead_code.has_introduced_warnings()
275    } else {
276        comparison
277            .dead_code
278            .records()
279            .iter()
280            .any(|record| record.effective_severity == Severity::Warn)
281    };
282    // The `complexity-*` rules decide if a finding blocks: `error` fails the
283    // verdict and `warn` gives `warn`. The `new-only` gate reads only the
284    // introduced findings.
285    let (complexity_errors, complexity_warnings) =
286        complexity_verdict_flags(gate, worsened, view, comparison);
287    let styling_errors = view.health.as_ref().is_some_and(|health| {
288        health
289            .report
290            .styling_findings
291            .iter()
292            .zip(comparison.styling.introduced())
293            .any(|(finding, introduced)| {
294                (!new_only || introduced) && styling_finding_gates(health.rules, &finding.code)
295            })
296    });
297    let duplication_findings = if new_only {
298        comparison.dupes.introduced_count()
299    } else {
300        summary.duplication_clone_groups
301    };
302    let duplication_errors = view.duplication.as_ref().is_some_and(|duplication| {
303        duplication_findings > 0
304            && duplication.threshold > 0.0
305            && duplication.duplication_percentage > duplication.threshold
306    });
307    if dead_code_errors || complexity_errors || styling_errors || duplication_errors {
308        AuditVerdict::Fail
309    } else if dead_code_warnings || complexity_warnings || duplication_findings > 0 {
310        AuditVerdict::Warn
311    } else {
312        AuditVerdict::Pass
313    }
314}
315
316/// Whether the head dead-code findings hold an error-severity finding, by the
317/// same rule that decides the exit code of `fallow dead-code`.
318fn dead_code_has_errors(view: &AuditAnalysesView<'_>) -> bool {
319    view.dead_code.as_ref().is_some_and(|dead_code| {
320        fallow_engine::error_severity::has_error_severity_issues(
321            dead_code.results,
322            &dead_code.config.rules,
323            Some(dead_code.config),
324            false,
325        )
326    })
327}
328
329fn attribution(
330    gate: AuditGate,
331    worsened: AuditWorsened,
332    comparison: &AuditComparison,
333    has_base: bool,
334) -> AuditAttribution {
335    if !has_base {
336        return AuditAttribution {
337            gate,
338            worsened,
339            ..AuditAttribution::default()
340        };
341    }
342    AuditAttribution {
343        gate,
344        worsened,
345        complexity_worsened: comparison.complexity_worsened_count(),
346        dead_code_introduced: comparison.dead_code.introduced_count(),
347        dead_code_inherited: comparison.dead_code.inherited_count(),
348        complexity_introduced: comparison.health.introduced_count(),
349        complexity_inherited: comparison.health.inherited_count(),
350        duplication_introduced: comparison.dupes.introduced_count(),
351        duplication_inherited: comparison.dupes.inherited_count(),
352    }
353}
354
355fn summary(view: &AuditAnalysesView<'_>, comparison: &AuditComparison) -> AuditSummary {
356    AuditSummary {
357        dead_code_issues: comparison.dead_code.visible_count(),
358        dead_code_has_errors: dead_code_has_errors(view),
359        dead_code_skipped: view.dead_code.is_none(),
360        complexity_skipped: view.health.is_none(),
361        duplication_skipped: view.duplication.is_none(),
362        complexity_findings: view
363            .health
364            .as_ref()
365            .map_or(0, |health| health.report.findings.len()),
366        max_cyclomatic: view.health.as_ref().and_then(|health| {
367            health
368                .report
369                .findings
370                .iter()
371                .map(|finding| finding.cyclomatic)
372                .max()
373        }),
374        duplication_clone_groups: view
375            .duplication
376            .as_ref()
377            .map_or(0, |duplication| duplication.clone_groups.len()),
378    }
379}