Skip to main content

backhopper_core/model/verdict/
reason.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//! `Verdict` and `Reason` with their classification methods.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use super::InapplicableReason;
12use crate::compat::arg_shape::ArgShape;
13use crate::model::names::{
14    Arity, CommitSha, FieldName, FunctionName, GitRef, MacroName, ModuleName, ProjectName,
15    RecordName, RelativePath, TagName, TypeName,
16};
17use crate::model::resolver_coverage::ResolverClass;
18use crate::model::symbol::SymbolRef;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
22#[serde(tag = "verdict", rename_all = "snake_case")]
23pub enum Verdict {
24    Compatible,
25    RequiresAdaptation { reasons: Vec<Reason> },
26    Incompatible { reasons: Vec<Reason> },
27    Inapplicable { reason: InapplicableReason },
28}
29
30impl Verdict {
31    pub fn from_reasons(reasons: Vec<Reason>) -> Self {
32        if reasons.is_empty() {
33            return Self::Compatible;
34        }
35        if reasons.iter().any(Reason::is_blocking) {
36            Self::Incompatible { reasons }
37        } else {
38            Self::RequiresAdaptation { reasons }
39        }
40    }
41
42    pub fn is_compatible(&self) -> bool {
43        matches!(self, Self::Compatible)
44    }
45
46    pub fn reasons(&self) -> &[Reason] {
47        match self {
48            Self::Compatible | Self::Inapplicable { .. } => &[],
49            Self::RequiresAdaptation { reasons } | Self::Incompatible { reasons } => reasons,
50        }
51    }
52}
53
54#[non_exhaustive]
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
57#[serde(tag = "kind", rename_all = "snake_case")]
58pub enum Reason {
59    MissingSymbol {
60        symbol: SymbolRef,
61        /// Earliest snapshot tag strictly later than the pin where the
62        /// symbol appears. `Some` reclassifies the reason as
63        /// adaptable: land the dep pin bump first.
64        first_seen_at_tag: Option<TagName>,
65        #[serde(default, skip_serializing_if = "Option::is_none")]
66        needs_pin_at_least: Option<NeedsPin>,
67        suggested_replacement: Option<SymbolRef>,
68    },
69    ArityChanged {
70        module: ModuleName,
71        function: FunctionName,
72        expected: Arity,
73        found: Vec<Arity>,
74        /// Earliest snapshot tag strictly later than the pin exporting
75        /// the expected arity. Same bump-first semantics as
76        /// `MissingSymbol::first_seen_at_tag`.
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        expected_available_at: Option<TagName>,
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        needs_pin_at_least: Option<NeedsPin>,
81    },
82    SignatureChanged {
83        module: ModuleName,
84        function: FunctionName,
85        arity: Arity,
86        expected_spec: String,
87        found_spec: String,
88    },
89    FileAbsent {
90        path: PathBuf,
91    },
92    ContextDrift {
93        path: PathBuf,
94        hunk_index: usize,
95    },
96    /// The contiguous preimage block (Context and Removed lines from
97    /// the hunk) was found verbatim at a different starting line.
98    /// Cherry-pick adapters handle this with the `-x` trailer; the
99    /// operator just needs the delta to confirm the patch's intent
100    /// still applies.
101    PreimageDrifted {
102        path: PathBuf,
103        hunk_index: usize,
104        line_delta: isize,
105    },
106    /// The hunk's preimage matches, but its added lines land at a file
107    /// edge where the target already has divergent content the base
108    /// lacked: an add/add a contiguous preimage match cannot detect. The
109    /// 3-way cherry-pick conflicts where the preimage-only check reads
110    /// clean. Non-blocking: the operator resolves the overlap.
111    PostimageCollision {
112        path: PathBuf,
113        hunk_index: usize,
114    },
115    /// The preimage block does not appear anywhere in the file. The
116    /// patch was authored against a tree where the lines existed; on
117    /// this pin they don't. Structurally inapplicable: the operator
118    /// needs an excerpt to reason about why.
119    PreimageMissing {
120        path: PathBuf,
121        hunk_index: usize,
122        preimage_excerpt: String,
123    },
124    DeprecatedUsage {
125        symbol: SymbolRef,
126        since: Option<TagName>,
127        replacement: Option<SymbolRef>,
128    },
129    NowHidden {
130        module: ModuleName,
131    },
132    RecordFieldsChanged {
133        record: RecordName,
134        expected: Vec<FieldName>,
135        found: Vec<FieldName>,
136    },
137    UnsupportedFileType {
138        path: PathBuf,
139    },
140    UntrackedModuleMissing {
141        module: ModuleName,
142    },
143    /// Call-site argument shapes don't satisfy any clause head at the
144    /// pin. Emitted when both the call and the pin's clause patterns
145    /// are concrete enough to compare; `Unknown` on either side
146    /// suppresses the reason.
147    ClauseMismatch {
148        module: ModuleName,
149        function: FunctionName,
150        arity: Arity,
151        call_args: Vec<ArgShape>,
152        pin_clauses: Vec<Vec<ArgShape>>,
153    },
154    /// Patch references an MFA owned by the self-project but absent from the
155    /// resolved self-snapshot. `suggested_source_for_prereq` is filled only
156    /// with `--suggest-prereqs`.
157    MissingPrereq {
158        symbol: SymbolRef,
159        self_branch: GitRef,
160        suggested_source_for_prereq: Option<CommitSha>,
161    },
162    /// Pre-flight syntactic damage in a touched file: an unresolved
163    /// merge-conflict marker, an exported function with no definition, etc.
164    SyntacticArtifact {
165        path: PathBuf,
166        line: usize,
167        artifact: ArtifactKind,
168    },
169    /// Patch changes a `-callback` signature; an implementer still has the old one.
170    BehaviourCallbackSignatureChanged {
171        behaviour: ModuleName,
172        callback: FunctionName,
173        arity: Arity,
174        expected_after_patch: String,
175        implementer: ModuleName,
176        implementer_signature: String,
177    },
178    /// Patch removes a required callback; implementer still exports it.
179    BehaviourCallbackRemoved {
180        behaviour: ModuleName,
181        callback: FunctionName,
182        arity: Arity,
183        implementer: ModuleName,
184    },
185    /// Patch adds a required callback; implementer is missing the export.
186    BehaviourCallbackAdded {
187        behaviour: ModuleName,
188        callback: FunctionName,
189        arity: Arity,
190        implementer: ModuleName,
191    },
192    /// Patch touches a path absent on pin, but a module of the same basename exists elsewhere.
193    ModuleRelocated {
194        module: ModuleName,
195        patch_path: PathBuf,
196    },
197    /// Wire-bearing macro value differs between source and pin.
198    WireConstantChanged {
199        module: ModuleName,
200        macro_name: String,
201        before: String,
202        after: String,
203    },
204    /// `version/0` advanced without the matching historical-implementation module.
205    HistoricalImplementationMissing {
206        module: ModuleName,
207        advertised_version_before: u32,
208        advertised_version_after: u32,
209        expected_historical_module: ModuleName,
210    },
211    /// Function bodies drifted at the same advertised `version/0`. Mixed-version cluster risk.
212    WireContractBodyDrift {
213        module: ModuleName,
214        functions: Vec<FunctionName>,
215        advertised_version: u32,
216    },
217    /// `version/0` decreased pin → patch.
218    WireContractRegression {
219        module: ModuleName,
220        pin_version: u32,
221        patch_version: u32,
222    },
223    /// Spec return shape differs between source and pin for a referenced MFA.
224    ReturnShapeMismatch {
225        module: ModuleName,
226        function: FunctionName,
227        arity: Arity,
228        source_signature: String,
229        pin_signature: String,
230    },
231    /// A type-reference inside a `-spec`, `-callback`, `-type`, or
232    /// `-opaque` attribute resolves to a name and arity that the pin
233    /// does not expose via `-export_type`. Non-blocking: a missing
234    /// type alias is a dialyzer-level concern, not a load-time crash.
235    MissingType {
236        module: ModuleName,
237        name: TypeName,
238        arity: Arity,
239    },
240    /// A touched path is absent from the target tree, but a
241    /// configured `[[path_translation]]` rewrites it to a path that
242    /// does exist on the target. Non-blocking: the operator (or the
243    /// cherry-pick driver) rewrites the path before applying.
244    PathRename {
245        source_path: PathBuf,
246        target_path: PathBuf,
247        translation: TranslationSource,
248    },
249    /// A touched path is absent on the target while others are present
250    /// and no `[[path_translation]]` rewrites it. Non-blocking: dropping
251    /// the hunks for this path lets the rest apply. The all-absent case
252    /// is `InapplicableReason::PathsMissingOnTarget`.
253    TargetPathAbsent {
254        path: RelativePath,
255    },
256    /// A `_SUITE.erl` file in the patch references `missing_module:f/n`
257    /// where `missing_module` resolves to no source file under any
258    /// configured `FamilyDefaults.test_helper_search_paths` on the
259    /// target tree. The cherry-pick compiles and the SUITE may even
260    /// load, but every call to the missing helper crashes at runtime:
261    /// the failure pattern seen in the Jun 2026 v4.1.x → v4.0.x round
262    /// (the `amqp_utils:connection_config/1` undef in
263    /// `amqp10_connection_max_SUITE`). Non-blocking by default: the
264    /// diagnostic equivalent (`Diagnostics.missing_test_modules`)
265    /// always fires; the `Reason` variant fires only when
266    /// `--target-repo-dir-path` is supplied and the resolution is
267    /// authoritative.
268    TestModuleSymbolMissing {
269        suite_path: RelativePath,
270        missing_module: ModuleName,
271        call_sites: Vec<TestCallSite>,
272    },
273    /// A touched `.erl` declares `-behaviour(behaviour)` (or
274    /// `-behavior(...)`) where `behaviour` resolves to no source
275    /// module on the target tree. `erlc` compiles with a warning,
276    /// then hook dispatch crashes at runtime. The `BehaviourCallback*`
277    /// variants model implementer drift; this one catches the
278    /// missing-behaviour-module case they do not.
279    BehaviourModuleMissing {
280        source_path: RelativePath,
281        behaviour: ModuleName,
282    },
283    /// A touched `.erl` or `.hrl` declares `-include(path)` or
284    /// `-include_lib(app/include/file.hrl)` against a target tree that
285    /// does not ship the resolved header. Compile-time failure rather
286    /// than runtime, but same user-facing shape as
287    /// `TestModuleSymbolMissing`: looked fine on source, broken on
288    /// target.
289    HeaderFileMissing {
290        source_path: RelativePath,
291        include_directive: IncludeDirective,
292        attempted_paths: Vec<RelativePath>,
293    },
294    /// A touched `.erl` or `.hrl` file references `?MACRO` on an added
295    /// line, but `MACRO` is defined nowhere the target tree reaches (not
296    /// in the file, its includes, or the patch itself) and is not
297    /// predefined. Compiles on source, dangles on target. Non-blocking:
298    /// the operator drops or adapts the reference.
299    MacroUndefinedOnTarget {
300        source_path: RelativePath,
301        macro_name: String,
302        line: u32,
303    },
304    /// A touched `.erl` or `.hrl` file uses `#record{}` on an added
305    /// line, but `record` is defined nowhere the target tree reaches and
306    /// is not added by the patch. Same check as `MacroUndefinedOnTarget`
307    /// for the record namespace; non-blocking.
308    RecordUndefinedOnTarget {
309        source_path: RelativePath,
310        record_name: RecordName,
311        line: u32,
312    },
313    /// A touched `.erl` file calls an unqualified `f/a` on an added line
314    /// that the target version of the same module neither defines,
315    /// exports, imports, nor inherits as an auto-imported BIF. The
316    /// same check for the function namespace; non-blocking.
317    LocalCallUndefinedOnTarget {
318        source_path: RelativePath,
319        function: FunctionName,
320        arity: Arity,
321        line: u32,
322    },
323    /// A touched `.erl` file calls a qualified `m:f/a` on an added line
324    /// whose module is a first-party module present on the target tree,
325    /// but the target version of that module does not export `f/a` (and
326    /// the patch does not add it). The qualified form of
327    /// `LocalCallUndefinedOnTarget`, resolved against the called
328    /// module's exports. Withheld when any pin snapshot already covers
329    /// the module, when it is absent from the tree, or when its export
330    /// surface is not fully readable. Non-blocking.
331    QualifiedCallUndefinedOnTarget {
332        source_path: RelativePath,
333        module: ModuleName,
334        function: FunctionName,
335        arity: Arity,
336        line: u32,
337    },
338    /// A qualified `m:f/a` call that resolves against the target tree
339    /// has a target-tree `-spec` return shape that disagrees with its
340    /// source-tree `-spec`. Same comparison as `ReturnShapeMismatch` on
341    /// the live, snapshot-free first-party axis: same comparator,
342    /// non-blocking like the rest of that axis. Only evaluated once the
343    /// call is confirmed to resolve; withheld when either side lacks a
344    /// `-spec`.
345    QualifiedCallReturnShapeDrift {
346        source_path: RelativePath,
347        module: ModuleName,
348        function: FunctionName,
349        arity: Arity,
350        source_signature: String,
351        target_signature: String,
352        line: u32,
353    },
354    /// An unqualified call that resolves against the target module's
355    /// own definitions has a target-tree `-spec` return shape that
356    /// disagrees with its source-tree `-spec`. The local-call form of
357    /// `QualifiedCallReturnShapeDrift`; imported resolutions are
358    /// withheld, since the callee's spec is in another module.
359    LocalCallReturnShapeDrift {
360        source_path: RelativePath,
361        function: FunctionName,
362        arity: Arity,
363        source_signature: String,
364        target_signature: String,
365        line: u32,
366    },
367    /// A `?MACRO` the patch uses is defined in the touched file itself
368    /// on both trees, exactly once each, and the normalized `-define`
369    /// bodies disagree: the compile-clean, behavior-divergent class.
370    /// Withheld for definitions reached any other way. Non-blocking.
371    MacroValueDrift {
372        source_path: RelativePath,
373        macro_name: String,
374        source_value: String,
375        target_value: String,
376        line: u32,
377    },
378    /// The target's version of a behaviour the patch declares requires
379    /// a callback the source's version does not, and the target does
380    /// not list it as optional: an implementation written against the
381    /// source is missing it. `line` is the `-behaviour` attribute's
382    /// line in the touched file. Non-blocking.
383    BehaviourCallbackAddedOnTarget {
384        source_path: RelativePath,
385        behaviour: ModuleName,
386        callback: FunctionName,
387        arity: Arity,
388        line: u32,
389    },
390    /// A callback both trees' versions of a declared behaviour declare
391    /// has a different normalized signature on the target. The whole
392    /// signature is compared, not just the return half: a callback
393    /// contract's argument types are the implementer's obligation.
394    /// Non-blocking.
395    BehaviourCallbackDriftOnTarget {
396        source_path: RelativePath,
397        behaviour: ModuleName,
398        callback: FunctionName,
399        arity: Arity,
400        source_signature: String,
401        target_signature: String,
402        line: u32,
403    },
404    /// Declared versioned-machine module is touched but the snapshot
405    /// has no recorded `versioned_machine_version` on `side`.
406    /// Non-blocking.
407    VersionedMachineSnapshotMissing {
408        module: ModuleName,
409        side: SnapshotSide,
410    },
411    /// Declared wire constants are touched but the snapshot's
412    /// `wire_constants` vector lacks the listed macros on `side`.
413    /// `macros` is sorted alphabetically. Non-blocking.
414    WireConstantBindingsMissing {
415        module: ModuleName,
416        macros: Vec<MacroName>,
417        side: SnapshotSide,
418    },
419    /// A first-party `m:f/a` referenced as arguments to a mocking or
420    /// rpc form (a meck expectation, `rpc:call`, `erpc:call`, or a CT
421    /// broker rpc helper) is neither exported nor defined by the target
422    /// tree's module. Non-blocking: a meck expectation can install a
423    /// function the module never had, so absence proves drift, not
424    /// failure.
425    IndirectCallUndefinedOnTarget {
426        source_path: RelativePath,
427        module: ModuleName,
428        function: FunctionName,
429        arity: Arity,
430        via: IndirectCallForm,
431        line: u32,
432    },
433    /// A touched `.erl` file adds an `-export_type([t/a])` entry naming
434    /// a type neither the target version of the same module declares
435    /// nor the patch adds. An `erlc` error, unlike `MissingType`, which
436    /// is dialyzer-level; non-blocking regardless, being a target-tree
437    /// finding rather than a pin verdict. Withheld when the target file
438    /// is unreadable or declares a `parse_transform`, or when the added
439    /// list names a macro.
440    ExportedTypeUndefinedOnTarget {
441        source_path: RelativePath,
442        type_name: TypeName,
443        arity: Arity,
444        line: u32,
445    },
446}
447
448/// Which side of a snapshot comparison the data is missing from.
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
450#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
451#[serde(rename_all = "snake_case")]
452pub enum SnapshotSide {
453    Source,
454    Target,
455    Both,
456}
457
458/// One `Module:Function/Arity` reference inside a `_SUITE.erl` that
459/// resolves to a module absent on the target tree. Carries the line
460/// number so the operator can jump straight to the call site.
461#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
462#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
463pub struct TestCallSite {
464    pub function: FunctionName,
465    pub arity: Arity,
466    pub line: u32,
467}
468
469/// `-include` and `-include_lib` are syntactically distinct: the
470/// former takes a relative-to-source path, the latter an
471/// `application/include/file.hrl` shape resolved through OTP's lib
472/// path. The variant preserves which form the source used so the
473/// remediation text can match.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476#[serde(tag = "form", rename_all = "snake_case")]
477pub enum IncludeDirective {
478    Include { path: String },
479    IncludeLib { path: String },
480}
481
482impl IncludeDirective {
483    pub fn path(&self) -> &str {
484        match self {
485            Self::Include { path } | Self::IncludeLib { path } => path,
486        }
487    }
488}
489
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
492#[serde(tag = "kind", rename_all = "snake_case")]
493pub enum TranslationSource {
494    ConfigStanza { name: String },
495    ExternalFile { path: PathBuf, name: String },
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
500#[serde(tag = "artifact_kind", rename_all = "snake_case")]
501pub enum ArtifactKind {
502    /// `<<<<<<<`, `=======`, `>>>>>>>`, or `||||||| ` marker present in a
503    /// touched file. The cherry-pick driver left a conflict unresolved.
504    ConflictMarker { marker: ConflictMarker },
505    /// `-export([f/N])` line without a matching clause defining `f/N`.
506    /// Compiles, then crashes at runtime on the first call.
507    ExportWithoutBody {
508        module: ModuleName,
509        function: FunctionName,
510        arity: Arity,
511    },
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
515#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
516#[serde(rename_all = "snake_case")]
517pub enum ConflictMarker {
518    /// `<<<<<<<` line: start of the local side.
519    Ours,
520    /// `=======` line: divider between sides.
521    Divider,
522    /// `>>>>>>>` line: end of the incoming side.
523    Theirs,
524    /// `||||||| ` line: base ancestor marker from a 3-way diff.
525    Ancestor,
526}
527
528impl ConflictMarker {
529    /// Returns the marker variant when `line` starts with at least seven
530    /// of the same `git` conflict-marker token followed by whitespace or
531    /// end of line. Returns `None` otherwise.
532    ///
533    /// `git`'s default marker length is seven; `merge.conflictStyle.markerSize`
534    /// can grow it, so the check accepts seven or more.
535    pub fn detect(line: &str) -> Option<Self> {
536        Self::detect_with_marker(line, '<', Self::Ours)
537            .or_else(|| Self::detect_with_marker(line, '=', Self::Divider))
538            .or_else(|| Self::detect_with_marker(line, '>', Self::Theirs))
539            .or_else(|| Self::detect_with_marker(line, '|', Self::Ancestor))
540    }
541
542    fn detect_with_marker(line: &str, token: char, marker: Self) -> Option<Self> {
543        const MIN_RUN: usize = 7;
544        let mut count = 0usize;
545        for c in line.chars() {
546            if c == token {
547                count += 1;
548                continue;
549            }
550            return if count >= MIN_RUN && (c == ' ' || c == '\t') {
551                Some(marker)
552            } else {
553                None
554            };
555        }
556        if count >= MIN_RUN { Some(marker) } else { None }
557    }
558}
559
560/// The apply outcomes a divergence predictor consumes, ordered by
561/// severity: a higher variant outranks a lower one on the same path, so
562/// dedup to one conflict per path is a `max`.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
564#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
565#[serde(rename_all = "snake_case")]
566pub enum ApplyConflictKind {
567    PostimageCollision,
568    PreimageMissing,
569    FileAbsent,
570}
571
572impl ApplyConflictKind {
573    /// Stable label used in clearance roll-ups and text output.
574    #[must_use]
575    pub fn as_str(self) -> &'static str {
576        match self {
577            Self::PostimageCollision => "postimage_collision",
578            Self::PreimageMissing => "preimage_missing",
579            Self::FileAbsent => "file_absent",
580        }
581    }
582}
583
584/// Which recognized call form carried an indirect MFA reference. Names
585/// the innermost form: a meck expectation reached through an rpc helper
586/// is `MeckExpect`, not the helper.
587#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589#[serde(rename_all = "snake_case")]
590pub enum IndirectCallForm {
591    MeckExpect,
592    RpcCall,
593    ErpcCall,
594    CtBrokerHelperRpc,
595    RabbitMiscRpcCall,
596}
597
598impl IndirectCallForm {
599    /// The Erlang form the extractor matched, for text output. Distinct
600    /// from the serde wire name on purpose.
601    #[must_use]
602    pub fn display_form(self) -> &'static str {
603        match self {
604            Self::MeckExpect => "meck:expect",
605            Self::RpcCall => "rpc:call",
606            Self::ErpcCall => "erpc:call",
607            Self::CtBrokerHelperRpc => "rabbit_ct_broker_helpers:rpc",
608            Self::RabbitMiscRpcCall => "rabbit_misc:rpc_call",
609        }
610    }
611}
612
613impl Reason {
614    /// The risky apply outcome and its path, or `None` for a clean or
615    /// non-apply reason. `PreimageDrifted` is excluded: a drifted preimage
616    /// applies cleanly. Wildcard-free so a future apply reason must
617    /// declare its risk here, not default silently to clean.
618    pub fn apply_conflict(&self) -> Option<(&Path, ApplyConflictKind)> {
619        match self {
620            Self::PostimageCollision { path, .. } => {
621                Some((path, ApplyConflictKind::PostimageCollision))
622            }
623            Self::PreimageMissing { path, .. } => Some((path, ApplyConflictKind::PreimageMissing)),
624            Self::FileAbsent { path } => Some((path, ApplyConflictKind::FileAbsent)),
625            Self::MissingSymbol { .. }
626            | Self::ArityChanged { .. }
627            | Self::SignatureChanged { .. }
628            | Self::ContextDrift { .. }
629            | Self::PreimageDrifted { .. }
630            | Self::DeprecatedUsage { .. }
631            | Self::NowHidden { .. }
632            | Self::RecordFieldsChanged { .. }
633            | Self::UnsupportedFileType { .. }
634            | Self::UntrackedModuleMissing { .. }
635            | Self::ClauseMismatch { .. }
636            | Self::MissingPrereq { .. }
637            | Self::SyntacticArtifact { .. }
638            | Self::BehaviourCallbackSignatureChanged { .. }
639            | Self::BehaviourCallbackRemoved { .. }
640            | Self::BehaviourCallbackAdded { .. }
641            | Self::ModuleRelocated { .. }
642            | Self::WireConstantChanged { .. }
643            | Self::HistoricalImplementationMissing { .. }
644            | Self::WireContractBodyDrift { .. }
645            | Self::WireContractRegression { .. }
646            | Self::ReturnShapeMismatch { .. }
647            | Self::MissingType { .. }
648            | Self::PathRename { .. }
649            | Self::TargetPathAbsent { .. }
650            | Self::TestModuleSymbolMissing { .. }
651            | Self::BehaviourModuleMissing { .. }
652            | Self::HeaderFileMissing { .. }
653            | Self::MacroUndefinedOnTarget { .. }
654            | Self::RecordUndefinedOnTarget { .. }
655            | Self::LocalCallUndefinedOnTarget { .. }
656            | Self::ExportedTypeUndefinedOnTarget { .. }
657            | Self::QualifiedCallUndefinedOnTarget { .. }
658            | Self::IndirectCallUndefinedOnTarget { .. }
659            | Self::QualifiedCallReturnShapeDrift { .. }
660            | Self::LocalCallReturnShapeDrift { .. }
661            | Self::MacroValueDrift { .. }
662            | Self::BehaviourCallbackAddedOnTarget { .. }
663            | Self::BehaviourCallbackDriftOnTarget { .. }
664            | Self::VersionedMachineSnapshotMissing { .. }
665            | Self::WireConstantBindingsMissing { .. } => None,
666        }
667    }
668
669    /// The class this reason flagged, for a predicted-class breakdown:
670    /// the absent-symbol family only. Not the corpus break class, which
671    /// is the symbol that actually broke the build (the observed outcome).
672    pub fn resolver_class(&self) -> Option<ResolverClass> {
673        match self {
674            Self::MissingSymbol { symbol, .. } | Self::MissingPrereq { symbol, .. } => {
675                Some(ResolverClass::of_symbol_kind(&symbol.kind))
676            }
677            Self::MissingType { .. } => Some(ResolverClass::Type),
678            Self::MacroUndefinedOnTarget { .. } | Self::MacroValueDrift { .. } => {
679                Some(ResolverClass::Macro)
680            }
681            Self::RecordUndefinedOnTarget { .. } => Some(ResolverClass::Record),
682            Self::ExportedTypeUndefinedOnTarget { .. } => Some(ResolverClass::Type),
683            Self::LocalCallUndefinedOnTarget { .. } | Self::LocalCallReturnShapeDrift { .. } => {
684                Some(ResolverClass::LocalCall)
685            }
686            Self::QualifiedCallUndefinedOnTarget { .. }
687            | Self::QualifiedCallReturnShapeDrift { .. } => Some(ResolverClass::QualifiedCall),
688            Self::IndirectCallUndefinedOnTarget { .. } => Some(ResolverClass::IndirectCall),
689            Self::HeaderFileMissing { .. } => Some(ResolverClass::Include),
690            Self::BehaviourModuleMissing { .. }
691            | Self::BehaviourCallbackAddedOnTarget { .. }
692            | Self::BehaviourCallbackDriftOnTarget { .. } => Some(ResolverClass::Behaviour),
693            Self::ArityChanged { .. }
694            | Self::SignatureChanged { .. }
695            | Self::FileAbsent { .. }
696            | Self::ContextDrift { .. }
697            | Self::PreimageDrifted { .. }
698            | Self::PostimageCollision { .. }
699            | Self::PreimageMissing { .. }
700            | Self::DeprecatedUsage { .. }
701            | Self::NowHidden { .. }
702            | Self::RecordFieldsChanged { .. }
703            | Self::UnsupportedFileType { .. }
704            | Self::UntrackedModuleMissing { .. }
705            | Self::ClauseMismatch { .. }
706            | Self::SyntacticArtifact { .. }
707            | Self::BehaviourCallbackSignatureChanged { .. }
708            | Self::BehaviourCallbackRemoved { .. }
709            | Self::BehaviourCallbackAdded { .. }
710            | Self::ModuleRelocated { .. }
711            | Self::WireConstantChanged { .. }
712            | Self::HistoricalImplementationMissing { .. }
713            | Self::WireContractBodyDrift { .. }
714            | Self::WireContractRegression { .. }
715            | Self::ReturnShapeMismatch { .. }
716            | Self::PathRename { .. }
717            | Self::TargetPathAbsent { .. }
718            | Self::TestModuleSymbolMissing { .. }
719            | Self::VersionedMachineSnapshotMissing { .. }
720            | Self::WireConstantBindingsMissing { .. } => None,
721        }
722    }
723
724    // wildcard-free so a new variant fails to compile until classified
725    pub fn is_blocking(&self) -> bool {
726        match self {
727            // exists at a later tag: adaptable once the dep pin bump lands
728            Self::MissingSymbol {
729                first_seen_at_tag: Some(_),
730                ..
731            } => false,
732            Self::ArityChanged {
733                expected_available_at: Some(_),
734                ..
735            } => false,
736            Self::MissingSymbol { .. }
737            | Self::ArityChanged { .. }
738            | Self::SignatureChanged { .. }
739            | Self::FileAbsent { .. }
740            | Self::NowHidden { .. }
741            | Self::RecordFieldsChanged { .. }
742            | Self::UntrackedModuleMissing { .. }
743            | Self::ClauseMismatch { .. }
744            | Self::MissingPrereq { .. }
745            | Self::SyntacticArtifact { .. }
746            | Self::BehaviourCallbackSignatureChanged { .. }
747            | Self::BehaviourCallbackRemoved { .. }
748            | Self::BehaviourCallbackAdded { .. }
749            | Self::WireConstantChanged { .. }
750            | Self::HistoricalImplementationMissing { .. }
751            | Self::WireContractRegression { .. }
752            | Self::ReturnShapeMismatch { .. } => true,
753            Self::ContextDrift { .. }
754            | Self::PreimageDrifted { .. }
755            | Self::PreimageMissing { .. }
756            | Self::PostimageCollision { .. }
757            | Self::DeprecatedUsage { .. }
758            | Self::UnsupportedFileType { .. }
759            | Self::ModuleRelocated { .. }
760            | Self::WireContractBodyDrift { .. }
761            | Self::MissingType { .. }
762            | Self::PathRename { .. }
763            | Self::TargetPathAbsent { .. }
764            | Self::TestModuleSymbolMissing { .. }
765            | Self::BehaviourModuleMissing { .. }
766            | Self::HeaderFileMissing { .. }
767            | Self::MacroUndefinedOnTarget { .. }
768            | Self::RecordUndefinedOnTarget { .. }
769            | Self::LocalCallUndefinedOnTarget { .. }
770            | Self::ExportedTypeUndefinedOnTarget { .. }
771            | Self::QualifiedCallUndefinedOnTarget { .. }
772            | Self::IndirectCallUndefinedOnTarget { .. }
773            | Self::QualifiedCallReturnShapeDrift { .. }
774            | Self::LocalCallReturnShapeDrift { .. }
775            | Self::MacroValueDrift { .. }
776            | Self::BehaviourCallbackAddedOnTarget { .. }
777            | Self::BehaviourCallbackDriftOnTarget { .. }
778            | Self::VersionedMachineSnapshotMissing { .. }
779            | Self::WireConstantBindingsMissing { .. } => false,
780        }
781    }
782
783    /// True for reasons that are facts about *files* rather than the
784    /// pin's API surface. These are the only reasons path routing may
785    /// drop for a pin that owns none of the touched paths.
786    /// Wildcard-free on purpose, like `is_blocking`.
787    pub fn is_path_scoped(&self) -> bool {
788        match self {
789            Self::FileAbsent { .. }
790            | Self::ModuleRelocated { .. }
791            | Self::PreimageDrifted { .. }
792            | Self::PreimageMissing { .. }
793            | Self::PostimageCollision { .. }
794            | Self::ContextDrift { .. }
795            | Self::PathRename { .. }
796            | Self::TargetPathAbsent { .. }
797            | Self::UnsupportedFileType { .. }
798            | Self::SyntacticArtifact { .. } => true,
799            Self::MissingSymbol { .. }
800            | Self::ArityChanged { .. }
801            | Self::SignatureChanged { .. }
802            | Self::DeprecatedUsage { .. }
803            | Self::NowHidden { .. }
804            | Self::RecordFieldsChanged { .. }
805            | Self::UntrackedModuleMissing { .. }
806            | Self::ClauseMismatch { .. }
807            | Self::MissingPrereq { .. }
808            | Self::BehaviourCallbackSignatureChanged { .. }
809            | Self::BehaviourCallbackRemoved { .. }
810            | Self::BehaviourCallbackAdded { .. }
811            | Self::WireConstantChanged { .. }
812            | Self::HistoricalImplementationMissing { .. }
813            | Self::WireContractBodyDrift { .. }
814            | Self::WireContractRegression { .. }
815            | Self::ReturnShapeMismatch { .. }
816            | Self::MissingType { .. }
817            | Self::TestModuleSymbolMissing { .. }
818            | Self::BehaviourModuleMissing { .. }
819            | Self::HeaderFileMissing { .. }
820            | Self::MacroUndefinedOnTarget { .. }
821            | Self::RecordUndefinedOnTarget { .. }
822            | Self::LocalCallUndefinedOnTarget { .. }
823            | Self::ExportedTypeUndefinedOnTarget { .. }
824            | Self::QualifiedCallUndefinedOnTarget { .. }
825            | Self::IndirectCallUndefinedOnTarget { .. }
826            | Self::QualifiedCallReturnShapeDrift { .. }
827            | Self::LocalCallReturnShapeDrift { .. }
828            | Self::MacroValueDrift { .. }
829            | Self::BehaviourCallbackAddedOnTarget { .. }
830            | Self::BehaviourCallbackDriftOnTarget { .. }
831            | Self::VersionedMachineSnapshotMissing { .. }
832            | Self::WireConstantBindingsMissing { .. } => false,
833        }
834    }
835}
836
837/// The minimum pin that satisfies a missing-symbol reason: the typed
838/// ordering payload q-port turns into a "bump must land first" edge.
839#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
840#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
841pub struct NeedsPin {
842    pub project: ProjectName,
843    pub tag: TagName,
844}