Skip to main content

backhopper_core/model/verdict/
diagnostics.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
5//! `Diagnostics` and its tally types.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use super::{AlreadyPresent, AlreadyPresentSkipped, DepPinDivergence, PinBump, Unanalyzed};
12use crate::model::names::{CommitSha, ModuleName, ProjectName, RecordName, RelativePath};
13
14/// Series-wide diagnostic envelope. Strictly separate from `Verdict`
15/// so untracked-call signals never leak into machine-readable verdicts.
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
18pub struct Diagnostics {
19    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
20    pub untracked_calls: BTreeMap<ModuleName, usize>,
21    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
22    pub untracked_records: BTreeMap<RecordName, usize>,
23    /// In-scope references on *context* lines that do not resolve at
24    /// some pin, per module. Pre-existing target facts: never reasons.
25    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26    pub context_refs_missing: BTreeMap<ModuleName, usize>,
27    /// Touched paths no configured project owns, tallied by their
28    /// first two path components. The evidence behind an
29    /// `Inapplicable { Untracked }` verdict.
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub unattributed_paths: BTreeMap<String, usize>,
32    #[serde(default, skip_serializing_if = "Unanalyzed::is_empty")]
33    pub unanalyzed: Unanalyzed,
34    /// SUITE names worth running for the patch, derived from touched paths.
35    /// Populated even when the verdict promotes to `Inapplicable` so that
36    /// schema-only and config-only diffs still surface relevant suites.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub suggested_suites: Vec<String>,
39    /// Per-`_SUITE.erl` map of `helper_module -> call_site_count`. The
40    /// always-on diagnostic form of
41    /// `Reason::TestModuleSymbolMissing`: emitted by the parse pass
42    /// even without a target tree (which is why the inner key is the
43    /// `ModuleName` rather than a resolved-or-not bit). When
44    /// `--target-repo-dir-path` is supplied the resolver re-checks this
45    /// map and promotes the absent ones to typed reasons. The nested
46    /// `BTreeMap` shape keeps the JSON envelope serialisable without
47    /// a custom composite-key encoder.
48    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49    pub missing_test_modules: BTreeMap<RelativePath, BTreeMap<ModuleName, usize>>,
50    /// Advisory: the patch's content appears to already exist on the
51    /// target (tier-1 identical patch, tier-2 content tally, or both).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub already_present: Option<AlreadyPresent>,
54    /// Why already-present detection was skipped, when it was.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub already_present_skipped: Option<AlreadyPresentSkipped>,
57    /// Dep pins that diverge between source and target trees and are
58    /// reachable from the apps this patch touches.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub dep_pin_divergence: Vec<DepPinDivergence>,
61    /// Dep pins this patch itself changes or introduces in the root
62    /// `rabbitmq-components.mk`.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub pin_bumps: Vec<PinBump>,
65    /// What the live qualified-call gate's return-shape check did with
66    /// each first-party resolved call: compared, or withheld and why.
67    #[serde(default, skip_serializing_if = "ShapeCheckTally::is_empty")]
68    pub qualified_call_shape_checks: ShapeCheckTally,
69    /// The same tally for local calls: resolved unqualified calls, compared or
70    /// withheld. Kept apart from the qualified tally so one axis's
71    /// coverage never reads as the other's.
72    #[serde(default, skip_serializing_if = "ShapeCheckTally::is_empty")]
73    pub local_call_shape_checks: ShapeCheckTally,
74    /// Same-file `-define` value comparisons over macros the patch
75    /// uses: compared, or withheld and why.
76    #[serde(default, skip_serializing_if = "MacroValueTally::is_empty")]
77    pub macro_value_checks: MacroValueTally,
78    /// What the indirect-reference check (meck expectations and rpc
79    /// forms) did with each recognized occurrence: resolved against the
80    /// target module, or seen with an unreadable arity and withheld.
81    #[serde(default, skip_serializing_if = "IndirectCallTally::is_empty")]
82    pub indirect_call_checks: IndirectCallTally,
83}
84
85impl Diagnostics {
86    pub fn is_empty(&self) -> bool {
87        // Destructure so adding a field forces this method to be updated.
88        let Self {
89            untracked_calls,
90            untracked_records,
91            context_refs_missing,
92            unattributed_paths,
93            unanalyzed,
94            suggested_suites,
95            missing_test_modules,
96            already_present,
97            already_present_skipped,
98            dep_pin_divergence,
99            pin_bumps,
100            qualified_call_shape_checks,
101            local_call_shape_checks,
102            macro_value_checks,
103            indirect_call_checks,
104        } = self;
105        untracked_calls.is_empty()
106            && untracked_records.is_empty()
107            && context_refs_missing.is_empty()
108            && unattributed_paths.is_empty()
109            && unanalyzed.is_empty()
110            && suggested_suites.is_empty()
111            && missing_test_modules.is_empty()
112            && already_present.is_none()
113            && already_present_skipped.is_none()
114            && dep_pin_divergence.is_empty()
115            && pin_bumps.is_empty()
116            && qualified_call_shape_checks.is_empty()
117            && local_call_shape_checks.is_empty()
118            && macro_value_checks.is_empty()
119            && indirect_call_checks.is_empty()
120    }
121
122    /// Record that `suite` references `helper`; bumps the call-site
123    /// counter. Convenience for the parse pass.
124    pub fn record_missing_test_module(&mut self, suite: RelativePath, helper: ModuleName) {
125        let inner = self.missing_test_modules.entry(suite).or_default();
126        *inner.entry(helper).or_insert(0) += 1;
127    }
128}
129
130/// How a tier-1 already-present match against the target branch was
131/// established.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
134#[serde(rename_all = "snake_case")]
135pub enum TargetMatchKind {
136    /// A walked target commit's `-x` trailer names the candidate (or
137    /// one of its trailer origins or inner PR commits).
138    TrailerIntersection,
139    /// One of the candidate's own trailer origins is already an
140    /// ancestor of the target tip.
141    TrailerAncestry,
142    /// A walked target commit's normalized patch hash equals the
143    /// candidate's.
144    PatchId,
145}
146
147/// A target commit found to carry the candidate's patch. The subject
148/// is included so consumers can auto-fill exclusion reasons without
149/// a second git query.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
152pub struct TargetMatch {
153    pub commit: CommitSha,
154    pub via: TargetMatchKind,
155    pub subject: String,
156}
157
158/// What the live qualified-call gate's return-shape check did with
159/// each first-party resolved call. Never reasons: a withhold is not a
160/// finding, but a round where most calls are shape-blind must read
161/// differently from a round where all of them were compared.
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
163#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
164pub struct ShapeCheckTally {
165    /// Both sides had a `-spec` that parsed to a known type; the shapes
166    /// were compared. Drift is visible in the reasons, not double-counted
167    /// here.
168    #[serde(default, skip_serializing_if = "is_zero")]
169    pub compared: usize,
170    /// One or both sides declare no `-spec` for the called function.
171    #[serde(default, skip_serializing_if = "is_zero")]
172    pub withheld_no_spec: usize,
173    /// Both sides have a spec but at least one parses to
174    /// `SpecType::Unknown`.
175    #[serde(default, skip_serializing_if = "is_zero")]
176    pub withheld_unknown_type: usize,
177    /// The callee module was not readable on the source side: no
178    /// checkout was supplied, or the checkout has no file at the
179    /// module's path (a defaulted `--repo-dir-path` that is not the
180    /// source tree lands here, not in `withheld_no_spec`).
181    #[serde(default, skip_serializing_if = "is_zero")]
182    pub withheld_no_source: usize,
183    /// The call resolved through an `-import`: the callee's `-spec`
184    /// is in another module, so the calling module's tables cannot
185    /// compare it. Local-call axis only.
186    #[serde(default, skip_serializing_if = "is_zero")]
187    pub withheld_imported: usize,
188}
189
190#[allow(clippy::trivially_copy_pass_by_ref)]
191fn is_zero(n: &usize) -> bool {
192    *n == 0
193}
194
195impl ShapeCheckTally {
196    pub fn is_empty(&self) -> bool {
197        *self == Self::default()
198    }
199
200    /// Fold another tally in, so the Erlang and Elixir extraction
201    /// streams sum into one counter.
202    pub fn merge(&mut self, other: Self) {
203        self.compared += other.compared;
204        self.withheld_no_spec += other.withheld_no_spec;
205        self.withheld_unknown_type += other.withheld_unknown_type;
206        self.withheld_no_source += other.withheld_no_source;
207        self.withheld_imported += other.withheld_imported;
208    }
209}
210
211/// What the macro-value check did with each `?MACRO` the patch uses:
212/// compared, or withheld and why. Same-file `-define`s only; anything
213/// reached another way is withheld, never guessed.
214#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
216pub struct MacroValueTally {
217    /// Defined exactly once same-file on both sides; values compared.
218    /// Drift is visible in the reasons, not double-counted here.
219    #[serde(default, skip_serializing_if = "is_zero")]
220    pub compared: usize,
221    /// Not defined same-file on both sides: the definition is in an
222    /// include, or moved between files across the trees.
223    #[serde(default, skip_serializing_if = "is_zero")]
224    pub withheld_definition_elsewhere: usize,
225    /// Defined more than once in the file on either side (`-ifdef`
226    /// branches): either value could be live.
227    #[serde(default, skip_serializing_if = "is_zero")]
228    pub withheld_multiple_defines: usize,
229    /// The touched file was not readable on the source side.
230    #[serde(default, skip_serializing_if = "is_zero")]
231    pub withheld_no_source: usize,
232}
233
234impl MacroValueTally {
235    pub fn is_empty(&self) -> bool {
236        *self == Self::default()
237    }
238
239    /// Fold another tally in, so the Erlang and Elixir extraction
240    /// streams sum into one counter.
241    pub fn merge(&mut self, other: Self) {
242        self.compared += other.compared;
243        self.withheld_definition_elsewhere += other.withheld_definition_elsewhere;
244        self.withheld_multiple_defines += other.withheld_multiple_defines;
245        self.withheld_no_source += other.withheld_no_source;
246    }
247}
248
249/// What the indirect-reference check did with each recognized form
250/// occurrence. `checked` counts deduplicated resolved sites, the same
251/// dedup the reasons use; flagged sites are visible in the reasons,
252/// not double-counted here.
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
255pub struct IndirectCallTally {
256    /// Module first-party, export and definition sets readable: the
257    /// membership check ran.
258    #[serde(default, skip_serializing_if = "is_zero")]
259    pub checked: usize,
260    /// Module and function were literal atoms but the arity source was
261    /// not statically readable: a variable argument list, a list with a
262    /// cons tail, or a non-literal arity argument.
263    #[serde(default, skip_serializing_if = "is_zero")]
264    pub withheld_dynamic: usize,
265}
266
267impl IndirectCallTally {
268    pub fn is_empty(&self) -> bool {
269        *self == Self::default()
270    }
271
272    /// Fold another tally in, so the Erlang and Elixir extraction
273    /// streams sum into one counter.
274    pub fn merge(&mut self, other: Self) {
275        self.checked += other.checked;
276        self.withheld_dynamic += other.withheld_dynamic;
277    }
278}
279
280/// Per-file hunk counters for the content-presence check.
281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
282#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
283pub struct HunkTally {
284    pub applied: usize,
285    pub considered: usize,
286    pub ambiguous: usize,
287    pub low_confidence: usize,
288}
289
290/// Tier-2 advisory: how much of the patch's post-image already exists
291/// in the named pin's tree. A tally, never a boolean: partial presence
292/// is common and the fraction is the signal.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
295pub struct ContentPresence {
296    /// The pin whose evaluation produced the tally. In cross-branch
297    /// mode the self pin resolves to the target branch, so its tally
298    /// is the target-tree answer.
299    pub pin: ProjectName,
300    pub hunks_already_applied: usize,
301    pub hunks_considered: usize,
302    pub hunks_ambiguous: usize,
303    pub hunks_low_confidence: usize,
304    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
305    pub per_file: BTreeMap<RelativePath, HunkTally>,
306}