Skip to main content

backhopper_core/model/verdict/
mod.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::mem;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use crate::model::apply::ApplyForecast;
12use crate::model::findings::TargetFindings;
13use crate::model::names::{
14    Arity, CommitSha, DependencyName, FunctionName, ModuleName, ProjectName, RecordName,
15    RelativePath, TagName,
16};
17use crate::model::pin::Pin;
18use crate::model::pr_commit::PrCommit;
19use crate::model::symbol::{SymbolKind, SymbolRef};
20
21mod diagnostics;
22mod reason;
23mod touched;
24
25pub use diagnostics::*;
26pub use reason::*;
27pub use touched::*;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
31pub struct PinVerdict {
32    pub pin: Pin,
33    pub verdict: Verdict,
34    #[serde(default)]
35    pub tracked_refs: usize,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub tracked_ref_details: Vec<SymbolRef>,
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub source_delta_details: Vec<SourceDelta>,
40    #[serde(default, skip_serializing_if = "TouchedKinds::is_empty")]
41    pub touched: TouchedKinds,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
46pub struct SourceDelta {
47    pub module: ModuleName,
48    pub function: FunctionName,
49    pub arity: Arity,
50    pub source_spec: String,
51    pub target_spec: String,
52}
53
54impl PinVerdict {
55    pub fn new(pin: Pin, verdict: Verdict) -> Self {
56        Self {
57            pin,
58            verdict,
59            tracked_refs: 0,
60            tracked_ref_details: Vec::new(),
61            source_delta_details: Vec::new(),
62            touched: TouchedKinds::default(),
63        }
64    }
65
66    #[must_use]
67    pub fn with_tracked_refs(mut self, n: usize) -> Self {
68        self.tracked_refs = n;
69        self
70    }
71
72    #[must_use]
73    pub fn with_tracked_ref_details(mut self, details: Vec<SymbolRef>) -> Self {
74        self.tracked_refs = details.len();
75        self.tracked_ref_details = details;
76        self
77    }
78
79    #[must_use]
80    pub fn with_source_delta_details(mut self, deltas: Vec<SourceDelta>) -> Self {
81        self.source_delta_details = deltas;
82        self
83    }
84
85    #[must_use]
86    pub fn with_touched(mut self, touched: TouchedKinds) -> Self {
87        self.touched = touched;
88        self
89    }
90}
91
92/// Saturating sum of `tracked_refs` over pins not owned by a self project.
93#[must_use]
94pub fn non_self_tracked(results: &[PinVerdict], self_projects: &BTreeSet<ProjectName>) -> u32 {
95    results
96        .iter()
97        .filter(|pin| !self_projects.contains(&pin.pin.project))
98        .fold(0u32, |acc, pin| {
99            acc.saturating_add(u32::try_from(pin.tracked_refs).unwrap_or(u32::MAX))
100        })
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
105pub struct SeriesVerdict {
106    pub results: Vec<PinVerdict>,
107    pub summary: SeriesSummary,
108}
109
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
112pub struct SeriesSummary {
113    pub compatible: u32,
114    pub requires_adaptation: u32,
115    pub incompatible: u32,
116    #[serde(default)]
117    pub inapplicable: u32,
118}
119
120impl SeriesSummary {
121    /// True when any pin needs attention: the one rule both the series
122    /// exit code and the round clearance gate on.
123    #[must_use]
124    pub fn is_blocking(&self) -> bool {
125        self.incompatible > 0 || self.requires_adaptation > 0
126    }
127}
128
129// 0 every pin clean; 3 partial-success when any pin needs attention
130pub mod exit {
131    pub const OK: i32 = 0;
132    pub const NEEDS_ATTENTION: i32 = 3;
133}
134
135impl SeriesVerdict {
136    pub fn from_results(results: Vec<PinVerdict>) -> Self {
137        let summary = summary_of(&results);
138        Self { results, summary }
139    }
140
141    /// Re-derives the summary after per-pin verdicts were mutated in
142    /// place (path routing, availability reclassification).
143    pub fn recompute_summary(&mut self) {
144        self.summary = summary_of(&self.results);
145    }
146
147    /// Promote each `Compatible`-with-zero-refs pin to `Inapplicable` when its
148    /// `TouchedKinds` indicates no analyzable Erlang surface. The summary
149    /// is recomputed from the rewritten results.
150    pub fn promote_inapplicable(self) -> Self {
151        let results: Vec<PinVerdict> = self
152            .results
153            .into_iter()
154            .map(|pv| {
155                if matches!(pv.verdict, Verdict::Compatible) && pv.tracked_refs == 0 {
156                    if let Some(reason) = pv.touched.inapplicable_reason() {
157                        return PinVerdict {
158                            verdict: Verdict::Inapplicable { reason },
159                            ..pv
160                        };
161                    }
162                }
163                pv
164            })
165            .collect();
166        Self::from_results(results)
167    }
168
169    // 0 when every pin is Compatible or Inapplicable; 3 otherwise
170    pub fn worst_exit_code(&self) -> i32 {
171        if self.summary.is_blocking() {
172            exit::NEEDS_ATTENTION
173        } else {
174            exit::OK
175        }
176    }
177}
178
179impl ContentPresence {
180    /// Every considered hunk is already applied: the strong signal.
181    pub fn fully_present(&self) -> bool {
182        self.hunks_considered > 0 && self.hunks_already_applied == self.hunks_considered
183    }
184}
185
186/// Advisory: the patch's content appears to already exist on the
187/// target. Never affects the verdict; q-port and the operator own
188/// the exclusion decision.
189#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
190#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
191pub struct AlreadyPresent {
192    /// Tier-1 hit: an identical patch landed on the target.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub identical: Option<TargetMatch>,
195    /// Tier-2 tally: the post-image is (partly) present in the tree.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub content: Option<ContentPresence>,
198}
199
200impl AlreadyPresent {
201    pub fn is_empty(&self) -> bool {
202        self.identical.is_none() && self.content.is_none()
203    }
204}
205
206/// A dependency whose `rabbitmq-components.mk` pin differs between
207/// the source commit's tree and the target tree, and that an app
208/// touched by the patch (transitively) depends on. Advisory only.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
211pub struct DepPinDivergence {
212    pub dep: DependencyName,
213    /// Pin at the source commit, e.g. `hex 2.16.0`.
214    pub source: String,
215    /// Pin at the target tree, e.g. `hex 2.13.0`.
216    pub target: String,
217}
218
219/// A dep pin the candidate commit itself changes or introduces in the
220/// root `rabbitmq-components.mk`. Advisory only: the verdict on a
221/// bump-only diff stays `Inapplicable`, this is the evidence behind it.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
224pub struct PinBump {
225    pub dep: DependencyName,
226    /// Pin before the commit, e.g. `hex 2.16.0`; `None` when the
227    /// commit introduces the pin.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub from: Option<String>,
230    /// Pin after the commit, e.g. `hex 2.17.1`.
231    pub to: String,
232    /// Snapshot-store assessment of the bumped-to version. Detection
233    /// is a pure function of the patch and may be cached; the store's
234    /// contents are not a cache-key input, so this field is filled
235    /// post-cache and cached values carry `None`.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub status: Option<BumpStatus>,
238}
239
240/// What the snapshot store knows about a bumped-to pin version.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
243#[serde(tag = "state", rename_all = "snake_case")]
244pub enum BumpStatus {
245    /// The bumped dep is not a tracked project; nothing to vet.
246    Untracked,
247    /// No snapshot for the bumped-to version; `note` carries the remedy.
248    SnapshotMissing {
249        note: String,
250    },
251    SnapshotPresent,
252}
253
254/// Why already-present detection could not run. Emitted instead of
255/// silence: an invisible skip would read as "not present".
256#[non_exhaustive]
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
259#[serde(tag = "reason", rename_all = "snake_case")]
260pub enum AlreadyPresentSkipped {
261    /// The candidate commit object is absent from the target object
262    /// store (an unfetched branch, usually).
263    CandidateMissingFromTargetOdb { sha: CommitSha },
264    /// The candidate and the target tip share no history.
265    NoMergeBase,
266    /// The target-side walk failed (shallow clone, corrupt object).
267    TargetWalkFailed { detail: String },
268}
269
270/// Counts of call sites the analyzer could not resolve: `apply/3`-style
271/// BIFs, and dispatch through a variable module or function. Informational only.
272#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
274pub struct Unanalyzed {
275    #[serde(default)]
276    pub apply: usize,
277    #[serde(default)]
278    pub variable_dispatch: usize,
279}
280
281impl Unanalyzed {
282    pub fn is_empty(&self) -> bool {
283        self.apply == 0 && self.variable_dispatch == 0
284    }
285}
286
287fn summary_of(results: &[PinVerdict]) -> SeriesSummary {
288    let mut summary = SeriesSummary::default();
289    for r in results {
290        match r.verdict {
291            Verdict::Compatible => summary.compatible += 1,
292            Verdict::RequiresAdaptation { .. } => summary.requires_adaptation += 1,
293            Verdict::Incompatible { .. } => summary.incompatible += 1,
294            Verdict::Inapplicable { .. } => summary.inapplicable += 1,
295        }
296    }
297    summary
298}
299
300/// One symbol-availability question the post-cache probe answers:
301/// "at which snapshot tag strictly later than this pin does the
302/// symbol first appear?"
303#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
304pub enum AvailabilityQuery {
305    ExactArity {
306        module: ModuleName,
307        function: FunctionName,
308        arity: Arity,
309    },
310    AnyArity {
311        module: ModuleName,
312        function: FunctionName,
313    },
314    Record {
315        name: RecordName,
316    },
317}
318
319impl AvailabilityQuery {
320    fn for_symbol(symbol: &SymbolRef) -> Option<Self> {
321        match &symbol.kind {
322            SymbolKind::Function { mfa } => Some(Self::ExactArity {
323                module: mfa.module.clone(),
324                function: mfa.function.clone(),
325                arity: mfa.arity,
326            }),
327            SymbolKind::FunctionAnyArity { module, function } => Some(Self::AnyArity {
328                module: module.clone(),
329                function: function.clone(),
330            }),
331            SymbolKind::Record { name } => Some(Self::Record { name: name.clone() }),
332            SymbolKind::Behaviour { .. }
333            | SymbolKind::Callback { .. }
334            | SymbolKind::Macro { .. }
335            | SymbolKind::Type { .. } => None,
336        }
337    }
338}
339
340/// The output of `Patch::evaluate_series`: verdicts plus diagnostics plus
341/// `PatchFacts` (source classifiers that drive downstream policy without
342/// being verdict reasons).
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
345pub struct SeriesEvaluation {
346    pub verdict: SeriesVerdict,
347    pub diagnostics: Diagnostics,
348    #[serde(default, skip_serializing_if = "PatchFacts::is_empty")]
349    pub patch_facts: PatchFacts,
350
351    /// Paths the analysed patch touched, in diff-encounter order.
352    /// Always emitted; an empty vec means the patch touched zero files.
353    #[serde(default)]
354    pub touched_paths: Vec<RelativePath>,
355
356    /// Inner PR-branch commits for 2-parent merge SHAs. `None` for
357    /// non-2-parent merges and non-merge SHAs.
358    ///
359    /// Do NOT add `skip_serializing_if = "Option::is_none"`. The
360    /// `None` vs `Some(vec![])` distinction is wire-load-bearing.
361    #[serde(default)]
362    pub pr_commits: Option<Vec<PrCommit>>,
363
364    /// Apply-axis prediction for this evaluation. `None` means no
365    /// target context was supplied, so the axis was not evaluated:
366    /// never "clean". A producer with a target context always emits
367    /// `Some`, even when the patch touched zero files.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub apply: Option<ApplyForecast>,
370
371    /// Symbol-axis target findings for this evaluation. `None` means
372    /// no target context was supplied, so the axis was not evaluated:
373    /// never "clean". A producer with a target context always emits
374    /// `Some`, even when no check produced a finding.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub target_findings: Option<TargetFindings>,
377}
378
379impl SeriesEvaluation {
380    /// Folds all three axes: a blocking verdict, a predicted apply
381    /// conflict, or a symbol finding on the target needs attention.
382    /// Findings gate on non-empty, not blocking: the same reasons on
383    /// an applicable pin read at least requires-adaptation.
384    pub fn worst_exit_code(&self) -> i32 {
385        if self.verdict.summary.is_blocking()
386            || self.apply.as_ref().is_some_and(ApplyForecast::has_conflict)
387            || self.target_findings.as_ref().is_some_and(|t| !t.is_empty())
388        {
389            exit::NEEDS_ATTENTION
390        } else {
391            exit::OK
392        }
393    }
394
395    /// Populates symbol-availability fields on missing-symbol and
396    /// arity-changed reasons, then reclassifies the affected pin
397    /// verdicts (`is_blocking` is field-sensitive). `lookup` answers
398    /// "earliest snapshot tag strictly later than this pin where the
399    /// queried symbol exists", or `None` (including for self pins).
400    /// Runs post-cache: the project's tag set is not a cache-key
401    /// input, so cached values must stay unclassified.
402    pub fn apply_symbol_availability<F>(&mut self, mut lookup: F)
403    where
404        F: FnMut(&Pin, &AvailabilityQuery) -> Option<TagName>,
405    {
406        let mut any_changed = false;
407        for pv in &mut self.verdict.results {
408            let pin = pv.pin.clone();
409            let reasons = match &mut pv.verdict {
410                Verdict::RequiresAdaptation { reasons } | Verdict::Incompatible { reasons } => {
411                    reasons
412                }
413                Verdict::Compatible | Verdict::Inapplicable { .. } => continue,
414            };
415            let mut changed = false;
416            for reason in reasons.iter_mut() {
417                match reason {
418                    Reason::MissingSymbol {
419                        symbol,
420                        first_seen_at_tag: first_seen @ None,
421                        needs_pin_at_least,
422                        ..
423                    } => {
424                        let Some(query) = AvailabilityQuery::for_symbol(symbol) else {
425                            continue;
426                        };
427                        if let Some(tag) = lookup(&pin, &query) {
428                            *first_seen = Some(tag.clone());
429                            *needs_pin_at_least = Some(NeedsPin {
430                                project: pin.project.clone(),
431                                tag,
432                            });
433                            changed = true;
434                        }
435                    }
436                    Reason::ArityChanged {
437                        module,
438                        function,
439                        expected,
440                        expected_available_at: available @ None,
441                        needs_pin_at_least,
442                        ..
443                    } => {
444                        let query = AvailabilityQuery::ExactArity {
445                            module: module.clone(),
446                            function: function.clone(),
447                            arity: *expected,
448                        };
449                        if let Some(tag) = lookup(&pin, &query) {
450                            *available = Some(tag.clone());
451                            *needs_pin_at_least = Some(NeedsPin {
452                                project: pin.project.clone(),
453                                tag,
454                            });
455                            changed = true;
456                        }
457                    }
458                    _ => {}
459                }
460            }
461            if changed {
462                let taken = mem::take(reasons);
463                pv.verdict = Verdict::from_reasons(taken);
464                any_changed = true;
465            }
466        }
467        if any_changed {
468            self.verdict.recompute_summary();
469        }
470    }
471}
472
473/// Source classifiers on the patch. Strictly separate from `Verdict` reasons.
474#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub struct PatchFacts {
477    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
478    pub logging_style: BTreeMap<PathBuf, FileLoggingStyle>,
479    #[serde(default, skip_serializing_if = "KhepriSignals::is_empty")]
480    pub khepri_signals: KhepriSignals,
481    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
482    pub introduces_versioned_record: BTreeSet<RecordName>,
483}
484
485impl PatchFacts {
486    pub fn is_empty(&self) -> bool {
487        self.logging_style.is_empty()
488            && self.khepri_signals.is_empty()
489            && self.introduces_versioned_record.is_empty()
490    }
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
495#[serde(rename_all = "snake_case")]
496pub enum LoggingStyle {
497    LoggerMacros,
498    RabbitLogModule,
499    Mixed,
500    None,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
505pub struct FileLoggingStyle {
506    pub dominant: LoggingStyle,
507    pub logger_macro_sites: usize,
508    pub rabbit_log_sites: usize,
509}
510
511#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
512#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
513pub struct KhepriSignals {
514    #[serde(default)]
515    pub touches_khepri_module: bool,
516    #[serde(default)]
517    pub uses_khepri_macros: bool,
518    #[serde(default)]
519    pub touches_only_khepri_branch: bool,
520    #[serde(default)]
521    pub touches_dual_branch: bool,
522}
523
524impl KhepriSignals {
525    // `&self` is required by serde `skip_serializing_if`.
526    #[allow(clippy::trivially_copy_pass_by_ref)]
527    pub fn is_empty(&self) -> bool {
528        !self.touches_khepri_module
529            && !self.uses_khepri_macros
530            && !self.touches_only_khepri_branch
531            && !self.touches_dual_branch
532    }
533}