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