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