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/// Snapshot-store status of 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 apply forecast and symbol-axis findings that a target context
341/// produces together: a check with no target evaluates neither.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct TargetAxis {
344    pub apply: ApplyForecast,
345    pub findings: TargetFindings,
346}
347
348/// Borrowed view of a present [`TargetAxis`], returned by
349/// [`TargetAxisSlot::get`].
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub struct TargetAxisRef<'a> {
352    pub apply: &'a ApplyForecast,
353    pub findings: &'a TargetFindings,
354}
355
356/// The wire shape `apply` and `target_findings` have always had:
357/// `None` together when no target context was supplied, `Some`
358/// together otherwise. `present` is the only writer of a `Some`, so
359/// the pair cannot travel apart in-process. Flattened into
360/// `SeriesEvaluation`, so the two keys keep their top-level names and
361/// their `skip_serializing_if`.
362#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
363#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
364pub struct TargetAxisSlot {
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    apply: Option<ApplyForecast>,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    target_findings: Option<TargetFindings>,
369}
370
371impl TargetAxisSlot {
372    #[must_use]
373    pub fn absent() -> Self {
374        Self::default()
375    }
376
377    #[must_use]
378    pub fn present(axis: TargetAxis) -> Self {
379        Self {
380            apply: Some(axis.apply),
381            target_findings: Some(axis.findings),
382        }
383    }
384
385    #[must_use]
386    pub fn get(&self) -> Option<TargetAxisRef<'_>> {
387        match (&self.apply, &self.target_findings) {
388            (Some(apply), Some(findings)) => Some(TargetAxisRef { apply, findings }),
389            _ => None,
390        }
391    }
392
393    /// `get` by value, for a caller that owns the evaluation and moves
394    /// the axis into an output struct.
395    #[must_use]
396    pub fn into_axis(self) -> Option<TargetAxis> {
397        match (self.apply, self.target_findings) {
398            (Some(apply), Some(findings)) => Some(TargetAxis { apply, findings }),
399            _ => None,
400        }
401    }
402
403    /// Rebuilds a slot from a wire pair that already carries the same
404    /// "both or neither" fact, such as `BatchResult`'s flat fields. A
405    /// partial pair (only ever possible from a pre-v14 envelope) reads
406    /// as absent, the reading that withholds most.
407    pub(crate) fn from_wire_pair(
408        apply: Option<ApplyForecast>,
409        target_findings: Option<TargetFindings>,
410    ) -> Self {
411        match (apply, target_findings) {
412            (Some(apply), Some(target_findings)) => Self {
413                apply: Some(apply),
414                target_findings: Some(target_findings),
415            },
416            _ => Self::absent(),
417        }
418    }
419}
420
421/// The output of `Patch::evaluate_series`: verdicts plus diagnostics plus
422/// `PatchFacts` (source classifiers that drive downstream policy without
423/// being verdict reasons).
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
426pub struct SeriesEvaluation {
427    pub verdict: SeriesVerdict,
428    pub diagnostics: Diagnostics,
429    #[serde(default, skip_serializing_if = "PatchFacts::is_empty")]
430    pub patch_facts: PatchFacts,
431
432    /// Paths the analysed patch touched, in diff-encounter order.
433    /// Always emitted; an empty vec means the patch touched zero files.
434    #[serde(default)]
435    pub touched_paths: Vec<RelativePath>,
436
437    /// Inner PR-branch commits for 2-parent merge SHAs. `None` for
438    /// non-2-parent merges and non-merge SHAs.
439    ///
440    /// Do NOT add `skip_serializing_if = "Option::is_none"`. The
441    /// `None` vs `Some(vec![])` distinction is part of the wire contract.
442    #[serde(default)]
443    pub pr_commits: Option<Vec<PrCommit>>,
444
445    /// Apply-axis prediction and symbol-axis target findings, together:
446    /// absent means no target context was supplied, so the axis was
447    /// not evaluated, never "clean". A producer with a target context
448    /// always fills both, even when the patch touched zero files or no
449    /// check produced a finding.
450    #[serde(flatten)]
451    pub target: TargetAxisSlot,
452}
453
454impl SeriesEvaluation {
455    /// Folds all three axes: a blocking verdict, a predicted apply
456    /// conflict, or a symbol finding on the target needs attention.
457    /// Findings gate on non-empty, not blocking: the same reasons on
458    /// an applicable pin read at least requires-adaptation.
459    pub fn worst_exit_code(&self) -> i32 {
460        let target = self.target.get();
461        if self.verdict.summary.is_blocking()
462            || target.is_some_and(|t| t.apply.has_conflict())
463            || target.is_some_and(|t| !t.findings.is_empty())
464        {
465            exit::NEEDS_ATTENTION
466        } else {
467            exit::OK
468        }
469    }
470
471    /// Populates symbol-availability fields on missing-symbol and
472    /// arity-changed reasons, then reclassifies the affected pin
473    /// verdicts (`is_blocking` is field-sensitive). `lookup` answers
474    /// "earliest snapshot tag strictly later than this pin where the
475    /// queried symbol exists", or `None` (including for self pins).
476    /// Runs post-cache: the project's tag set is not a cache-key
477    /// input, so cached values must stay unclassified.
478    pub fn apply_symbol_availability<F>(&mut self, mut lookup: F)
479    where
480        F: FnMut(&Pin, &AvailabilityQuery) -> Option<TagName>,
481    {
482        let mut any_changed = false;
483        for pv in &mut self.verdict.results {
484            let pin = pv.pin.clone();
485            let reasons = match &mut pv.verdict {
486                Verdict::RequiresAdaptation { reasons } | Verdict::Incompatible { reasons } => {
487                    reasons
488                }
489                Verdict::Compatible | Verdict::Inapplicable { .. } => continue,
490            };
491            let mut changed = false;
492            for reason in reasons.iter_mut() {
493                match reason {
494                    Reason::MissingSymbol {
495                        symbol,
496                        first_seen_at_tag: first_seen @ None,
497                        needs_pin_at_least,
498                        ..
499                    } => {
500                        let Some(query) = AvailabilityQuery::for_symbol(symbol) else {
501                            continue;
502                        };
503                        if let Some(tag) = lookup(&pin, &query) {
504                            *first_seen = Some(tag.clone());
505                            *needs_pin_at_least = Some(NeedsPin {
506                                project: pin.project.clone(),
507                                tag,
508                            });
509                            changed = true;
510                        }
511                    }
512                    Reason::ArityChanged {
513                        module,
514                        function,
515                        expected,
516                        expected_available_at: available @ None,
517                        needs_pin_at_least,
518                        ..
519                    } => {
520                        let query = AvailabilityQuery::ExactArity {
521                            module: module.clone(),
522                            function: function.clone(),
523                            arity: *expected,
524                        };
525                        if let Some(tag) = lookup(&pin, &query) {
526                            *available = Some(tag.clone());
527                            *needs_pin_at_least = Some(NeedsPin {
528                                project: pin.project.clone(),
529                                tag,
530                            });
531                            changed = true;
532                        }
533                    }
534                    _ => {}
535                }
536            }
537            if changed {
538                let taken = mem::take(reasons);
539                pv.verdict = Verdict::from_reasons(taken);
540                any_changed = true;
541            }
542        }
543        if any_changed {
544            self.verdict.recompute_summary();
545        }
546    }
547}
548
549/// Source classifiers on the patch. Strictly separate from `Verdict` reasons.
550#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
551#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
552pub struct PatchFacts {
553    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
554    pub logging_style: BTreeMap<PathBuf, FileLoggingStyle>,
555    #[serde(default, skip_serializing_if = "KhepriSignals::is_empty")]
556    pub khepri_signals: KhepriSignals,
557    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
558    pub introduces_versioned_record: BTreeSet<RecordName>,
559}
560
561impl PatchFacts {
562    pub fn is_empty(&self) -> bool {
563        self.logging_style.is_empty()
564            && self.khepri_signals.is_empty()
565            && self.introduces_versioned_record.is_empty()
566    }
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
570#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
571#[serde(rename_all = "snake_case")]
572pub enum LoggingStyle {
573    LoggerMacros,
574    RabbitLogModule,
575    Mixed,
576    None,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
580#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
581pub struct FileLoggingStyle {
582    pub dominant: LoggingStyle,
583    pub logger_macro_sites: usize,
584    pub rabbit_log_sites: usize,
585}
586
587#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589pub struct KhepriSignals {
590    #[serde(default)]
591    pub touches_khepri_module: bool,
592    #[serde(default)]
593    pub uses_khepri_macros: bool,
594    #[serde(default)]
595    pub touches_only_khepri_branch: bool,
596    #[serde(default)]
597    pub touches_dual_branch: bool,
598}
599
600impl KhepriSignals {
601    // &self is required by serde skip_serializing_if.
602    #[allow(clippy::trivially_copy_pass_by_ref)]
603    pub fn is_empty(&self) -> bool {
604        !self.touches_khepri_module
605            && !self.uses_khepri_macros
606            && !self.touches_only_khepri_branch
607            && !self.touches_dual_branch
608    }
609}