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 see. 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 is the
146    /// escape hatch that 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 that bit 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. Sister verdict to the
277    /// existing `BehaviourCallback*` variants which model implementer
278    /// drift; this one catches the missing-behaviour-module case the
279    /// existing variants do not.
280    BehaviourModuleMissing {
281        source_path: RelativePath,
282        behaviour: ModuleName,
283    },
284    /// A touched `.erl` or `.hrl` declares `-include(path)` or
285    /// `-include_lib(app/include/file.hrl)` against a target tree that
286    /// does not ship the resolved header. Compile-time failure rather
287    /// than runtime, but same user-facing shape as
288    /// `TestModuleSymbolMissing`: looked fine on source, broken on
289    /// target.
290    HeaderFileMissing {
291        source_path: RelativePath,
292        include_directive: IncludeDirective,
293        attempted_paths: Vec<RelativePath>,
294    },
295    /// A touched `.erl` or `.hrl` file references `?MACRO` on an added
296    /// line, but `MACRO` is defined nowhere the target tree reaches (not
297    /// in the file, its includes, or the patch itself) and is not
298    /// predefined. Compiles on source, dangles on target. Non-blocking:
299    /// the operator drops or adapts the reference.
300    MacroUndefinedOnTarget {
301        source_path: RelativePath,
302        macro_name: String,
303        line: u32,
304    },
305    /// A touched `.erl` or `.hrl` file uses `#record{}` on an added
306    /// line, but `record` is defined nowhere the target tree reaches and
307    /// is not added by the patch. Sister of `MacroUndefinedOnTarget` for
308    /// the record namespace; non-blocking.
309    RecordUndefinedOnTarget {
310        source_path: RelativePath,
311        record_name: RecordName,
312        line: u32,
313    },
314    /// A touched `.erl` file calls an unqualified `f/a` on an added line
315    /// that the target version of the same module neither defines,
316    /// exports, imports, nor inherits as an auto-imported BIF. The
317    /// function-namespace counterpart; non-blocking.
318    LocalCallUndefinedOnTarget {
319        source_path: RelativePath,
320        function: FunctionName,
321        arity: Arity,
322        line: u32,
323    },
324    /// A touched `.erl` file calls a qualified `m:f/a` on an added line
325    /// whose module is a first-party module present on the target tree,
326    /// but the target version of that module does not export `f/a` (and
327    /// the patch does not add it). The qualified counterpart of
328    /// `LocalCallUndefinedOnTarget`, resolved against the called
329    /// module's exports. Withheld when any pin snapshot already covers
330    /// the module, when it is absent from the tree, or when its export
331    /// surface is not fully readable. Non-blocking.
332    QualifiedCallUndefinedOnTarget {
333        source_path: RelativePath,
334        module: ModuleName,
335        function: FunctionName,
336        arity: Arity,
337        line: u32,
338    },
339    /// A qualified `m:f/a` call that resolves against the target tree
340    /// has a target-tree `-spec` return shape that disagrees with its
341    /// source-tree `-spec`. Sister of `ReturnShapeMismatch` for the
342    /// live, snapshot-free first-party axis: same comparator,
343    /// non-blocking like the rest of that axis. Only evaluated once the
344    /// call is confirmed to resolve; withheld when either side lacks a
345    /// `-spec`.
346    QualifiedCallReturnShapeDrift {
347        source_path: RelativePath,
348        module: ModuleName,
349        function: FunctionName,
350        arity: Arity,
351        source_signature: String,
352        target_signature: String,
353        line: u32,
354    },
355    /// An unqualified call that resolves against the target module's
356    /// own definitions has a target-tree `-spec` return shape that
357    /// disagrees with its source-tree `-spec`. The local twin of
358    /// `QualifiedCallReturnShapeDrift`; imported resolutions are
359    /// withheld, since the callee's spec lives in another module.
360    LocalCallReturnShapeDrift {
361        source_path: RelativePath,
362        function: FunctionName,
363        arity: Arity,
364        source_signature: String,
365        target_signature: String,
366        line: u32,
367    },
368    /// A `?MACRO` the patch uses is defined in the touched file itself
369    /// on both trees, exactly once each, and the normalized `-define`
370    /// bodies disagree: the compile-clean, behavior-divergent class.
371    /// Withheld for definitions reached any other way. Non-blocking.
372    MacroValueDrift {
373        source_path: RelativePath,
374        macro_name: String,
375        source_value: String,
376        target_value: String,
377        line: u32,
378    },
379    /// The target's version of a behaviour the patch declares requires
380    /// a callback the source's version does not, and the target does
381    /// not list it as optional: an implementation written against the
382    /// source is missing it. `line` is the `-behaviour` attribute's
383    /// line in the touched file. Non-blocking.
384    BehaviourCallbackAddedOnTarget {
385        source_path: RelativePath,
386        behaviour: ModuleName,
387        callback: FunctionName,
388        arity: Arity,
389        line: u32,
390    },
391    /// A callback both trees' versions of a declared behaviour declare
392    /// has a different normalized signature on the target. The whole
393    /// signature is compared, not just the return half: a callback
394    /// contract's argument types are the implementer's obligation.
395    /// Non-blocking.
396    BehaviourCallbackDriftOnTarget {
397        source_path: RelativePath,
398        behaviour: ModuleName,
399        callback: FunctionName,
400        arity: Arity,
401        source_signature: String,
402        target_signature: String,
403        line: u32,
404    },
405    /// Declared versioned-machine module is touched but the snapshot
406    /// has no recorded `versioned_machine_version` on `side`.
407    /// Non-blocking.
408    VersionedMachineSnapshotMissing {
409        module: ModuleName,
410        side: SnapshotSide,
411    },
412    /// Declared wire constants are touched but the snapshot's
413    /// `wire_constants` vector lacks the listed macros on `side`.
414    /// `macros` is sorted alphabetically. Non-blocking.
415    WireConstantBindingsMissing {
416        module: ModuleName,
417        macros: Vec<MacroName>,
418        side: SnapshotSide,
419    },
420    /// A first-party `m:f/a` referenced as arguments to a mocking or
421    /// rpc form (a meck expectation, `rpc:call`, `erpc:call`, or a CT
422    /// broker rpc helper) is neither exported nor defined by the target
423    /// tree's module. Non-blocking: a meck expectation can install a
424    /// function the module never had, so absence proves drift, not
425    /// failure.
426    IndirectCallUndefinedOnTarget {
427        source_path: RelativePath,
428        module: ModuleName,
429        function: FunctionName,
430        arity: Arity,
431        via: IndirectCallForm,
432        line: u32,
433    },
434    /// A touched `.erl` file adds an `-export_type([t/a])` entry naming
435    /// a type neither the target version of the same module declares
436    /// nor the patch adds. An `erlc` error, unlike `MissingType`, which
437    /// is dialyzer-level; non-blocking regardless, being a target-tree
438    /// finding rather than a pin verdict. Withheld when the target file
439    /// is unreadable or declares a `parse_transform`, or when the added
440    /// list names a macro.
441    ExportedTypeUndefinedOnTarget {
442        source_path: RelativePath,
443        type_name: TypeName,
444        arity: Arity,
445        line: u32,
446    },
447}
448
449/// Which side of a snapshot comparison the data is missing from.
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
451#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
452#[serde(rename_all = "snake_case")]
453pub enum SnapshotSide {
454    Source,
455    Target,
456    Both,
457}
458
459/// One `Module:Function/Arity` reference inside a `_SUITE.erl` that
460/// resolves to a module absent on the target tree. Carries the line
461/// number so the operator can jump straight to the call site.
462#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
463#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
464pub struct TestCallSite {
465    pub function: FunctionName,
466    pub arity: Arity,
467    pub line: u32,
468}
469
470/// `-include` and `-include_lib` are syntactically distinct: the
471/// former takes a relative-to-source path, the latter an
472/// `application/include/file.hrl` shape resolved through OTP's lib
473/// path. The variant preserves which form the source used so the
474/// remediation text can match.
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
476#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
477#[serde(tag = "form", rename_all = "snake_case")]
478pub enum IncludeDirective {
479    Include { path: String },
480    IncludeLib { path: String },
481}
482
483impl IncludeDirective {
484    pub fn path(&self) -> &str {
485        match self {
486            Self::Include { path } | Self::IncludeLib { path } => path,
487        }
488    }
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
493#[serde(tag = "kind", rename_all = "snake_case")]
494pub enum TranslationSource {
495    ConfigStanza { name: String },
496    ExternalFile { path: PathBuf, name: String },
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
501#[serde(tag = "artifact_kind", rename_all = "snake_case")]
502pub enum ArtifactKind {
503    /// `<<<<<<<`, `=======`, `>>>>>>>`, or `||||||| ` marker present in a
504    /// touched file. The cherry-pick driver left a conflict unresolved.
505    ConflictMarker { marker: ConflictMarker },
506    /// `-export([f/N])` line without a matching clause defining `f/N`.
507    /// Compiles, then crashes at runtime on the first call.
508    ExportWithoutBody {
509        module: ModuleName,
510        function: FunctionName,
511        arity: Arity,
512    },
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
517#[serde(rename_all = "snake_case")]
518pub enum ConflictMarker {
519    /// `<<<<<<<` line: start of the local side.
520    Ours,
521    /// `=======` line: divider between sides.
522    Divider,
523    /// `>>>>>>>` line: end of the incoming side.
524    Theirs,
525    /// `||||||| ` line: base ancestor marker from a 3-way diff.
526    Ancestor,
527}
528
529impl ConflictMarker {
530    /// Returns the marker variant when `line` starts with at least seven
531    /// of the same `git` conflict-marker token followed by whitespace or
532    /// end of line. Returns `None` otherwise.
533    ///
534    /// `git`'s default marker length is seven; `merge.conflictStyle.markerSize`
535    /// can grow it, so the check accepts seven or more.
536    pub fn detect(line: &str) -> Option<Self> {
537        Self::detect_with_marker(line, '<', Self::Ours)
538            .or_else(|| Self::detect_with_marker(line, '=', Self::Divider))
539            .or_else(|| Self::detect_with_marker(line, '>', Self::Theirs))
540            .or_else(|| Self::detect_with_marker(line, '|', Self::Ancestor))
541    }
542
543    fn detect_with_marker(line: &str, token: char, marker: Self) -> Option<Self> {
544        const MIN_RUN: usize = 7;
545        let mut count = 0usize;
546        for c in line.chars() {
547            if c == token {
548                count += 1;
549                continue;
550            }
551            return if count >= MIN_RUN && (c == ' ' || c == '\t') {
552                Some(marker)
553            } else {
554                None
555            };
556        }
557        if count >= MIN_RUN { Some(marker) } else { None }
558    }
559}
560
561/// The apply outcomes a divergence predictor consumes, ordered by
562/// severity: a higher variant outranks a lower one on the same path, so
563/// dedup to one conflict per path is a `max`.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
565#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
566#[serde(rename_all = "snake_case")]
567pub enum ApplyConflictKind {
568    PostimageCollision,
569    PreimageMissing,
570    FileAbsent,
571}
572
573impl ApplyConflictKind {
574    /// Stable label used in clearance roll-ups and text output.
575    #[must_use]
576    pub fn as_str(self) -> &'static str {
577        match self {
578            Self::PostimageCollision => "postimage_collision",
579            Self::PreimageMissing => "preimage_missing",
580            Self::FileAbsent => "file_absent",
581        }
582    }
583}
584
585/// Which recognized call form carried an indirect MFA reference. Names
586/// the innermost form: a meck expectation reached through an rpc helper
587/// is `MeckExpect`, not the helper.
588#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
589#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
590#[serde(rename_all = "snake_case")]
591pub enum IndirectCallForm {
592    MeckExpect,
593    RpcCall,
594    ErpcCall,
595    CtBrokerHelperRpc,
596    RabbitMiscRpcCall,
597}
598
599impl IndirectCallForm {
600    /// The Erlang form the extractor matched, for text output. Distinct
601    /// from the serde wire name on purpose.
602    #[must_use]
603    pub fn display_form(self) -> &'static str {
604        match self {
605            Self::MeckExpect => "meck:expect",
606            Self::RpcCall => "rpc:call",
607            Self::ErpcCall => "erpc:call",
608            Self::CtBrokerHelperRpc => "rabbit_ct_broker_helpers:rpc",
609            Self::RabbitMiscRpcCall => "rabbit_misc:rpc_call",
610        }
611    }
612}
613
614impl Reason {
615    /// The risky apply outcome and its path, or `None` for a clean or
616    /// non-apply reason. `PreimageDrifted` is excluded: a drifted preimage
617    /// applies cleanly. Wildcard-free so a future apply reason must
618    /// declare its risk here, not default silently to clean.
619    pub fn apply_conflict(&self) -> Option<(&Path, ApplyConflictKind)> {
620        match self {
621            Self::PostimageCollision { path, .. } => {
622                Some((path, ApplyConflictKind::PostimageCollision))
623            }
624            Self::PreimageMissing { path, .. } => Some((path, ApplyConflictKind::PreimageMissing)),
625            Self::FileAbsent { path } => Some((path, ApplyConflictKind::FileAbsent)),
626            Self::MissingSymbol { .. }
627            | Self::ArityChanged { .. }
628            | Self::SignatureChanged { .. }
629            | Self::ContextDrift { .. }
630            | Self::PreimageDrifted { .. }
631            | Self::DeprecatedUsage { .. }
632            | Self::NowHidden { .. }
633            | Self::RecordFieldsChanged { .. }
634            | Self::UnsupportedFileType { .. }
635            | Self::UntrackedModuleMissing { .. }
636            | Self::ClauseMismatch { .. }
637            | Self::MissingPrereq { .. }
638            | Self::SyntacticArtifact { .. }
639            | Self::BehaviourCallbackSignatureChanged { .. }
640            | Self::BehaviourCallbackRemoved { .. }
641            | Self::BehaviourCallbackAdded { .. }
642            | Self::ModuleRelocated { .. }
643            | Self::WireConstantChanged { .. }
644            | Self::HistoricalImplementationMissing { .. }
645            | Self::WireContractBodyDrift { .. }
646            | Self::WireContractRegression { .. }
647            | Self::ReturnShapeMismatch { .. }
648            | Self::MissingType { .. }
649            | Self::PathRename { .. }
650            | Self::TargetPathAbsent { .. }
651            | Self::TestModuleSymbolMissing { .. }
652            | Self::BehaviourModuleMissing { .. }
653            | Self::HeaderFileMissing { .. }
654            | Self::MacroUndefinedOnTarget { .. }
655            | Self::RecordUndefinedOnTarget { .. }
656            | Self::LocalCallUndefinedOnTarget { .. }
657            | Self::ExportedTypeUndefinedOnTarget { .. }
658            | Self::QualifiedCallUndefinedOnTarget { .. }
659            | Self::IndirectCallUndefinedOnTarget { .. }
660            | Self::QualifiedCallReturnShapeDrift { .. }
661            | Self::LocalCallReturnShapeDrift { .. }
662            | Self::MacroValueDrift { .. }
663            | Self::BehaviourCallbackAddedOnTarget { .. }
664            | Self::BehaviourCallbackDriftOnTarget { .. }
665            | Self::VersionedMachineSnapshotMissing { .. }
666            | Self::WireConstantBindingsMissing { .. } => None,
667        }
668    }
669
670    /// The class this reason flagged, for a predicted-class breakdown:
671    /// the absent-symbol family only. Not the corpus break class, which
672    /// is the symbol that actually broke the build (the observed outcome).
673    pub fn resolver_class(&self) -> Option<ResolverClass> {
674        match self {
675            Self::MissingSymbol { symbol, .. } | Self::MissingPrereq { symbol, .. } => {
676                Some(ResolverClass::of_symbol_kind(&symbol.kind))
677            }
678            Self::MissingType { .. } => Some(ResolverClass::Type),
679            Self::MacroUndefinedOnTarget { .. } | Self::MacroValueDrift { .. } => {
680                Some(ResolverClass::Macro)
681            }
682            Self::RecordUndefinedOnTarget { .. } => Some(ResolverClass::Record),
683            Self::ExportedTypeUndefinedOnTarget { .. } => Some(ResolverClass::Type),
684            Self::LocalCallUndefinedOnTarget { .. } | Self::LocalCallReturnShapeDrift { .. } => {
685                Some(ResolverClass::LocalCall)
686            }
687            Self::QualifiedCallUndefinedOnTarget { .. }
688            | Self::QualifiedCallReturnShapeDrift { .. } => Some(ResolverClass::QualifiedCall),
689            Self::IndirectCallUndefinedOnTarget { .. } => Some(ResolverClass::IndirectCall),
690            Self::HeaderFileMissing { .. } => Some(ResolverClass::Include),
691            Self::BehaviourModuleMissing { .. }
692            | Self::BehaviourCallbackAddedOnTarget { .. }
693            | Self::BehaviourCallbackDriftOnTarget { .. } => Some(ResolverClass::Behaviour),
694            Self::ArityChanged { .. }
695            | Self::SignatureChanged { .. }
696            | Self::FileAbsent { .. }
697            | Self::ContextDrift { .. }
698            | Self::PreimageDrifted { .. }
699            | Self::PostimageCollision { .. }
700            | Self::PreimageMissing { .. }
701            | Self::DeprecatedUsage { .. }
702            | Self::NowHidden { .. }
703            | Self::RecordFieldsChanged { .. }
704            | Self::UnsupportedFileType { .. }
705            | Self::UntrackedModuleMissing { .. }
706            | Self::ClauseMismatch { .. }
707            | Self::SyntacticArtifact { .. }
708            | Self::BehaviourCallbackSignatureChanged { .. }
709            | Self::BehaviourCallbackRemoved { .. }
710            | Self::BehaviourCallbackAdded { .. }
711            | Self::ModuleRelocated { .. }
712            | Self::WireConstantChanged { .. }
713            | Self::HistoricalImplementationMissing { .. }
714            | Self::WireContractBodyDrift { .. }
715            | Self::WireContractRegression { .. }
716            | Self::ReturnShapeMismatch { .. }
717            | Self::PathRename { .. }
718            | Self::TargetPathAbsent { .. }
719            | Self::TestModuleSymbolMissing { .. }
720            | Self::VersionedMachineSnapshotMissing { .. }
721            | Self::WireConstantBindingsMissing { .. } => None,
722        }
723    }
724
725    // wildcard-free so a new variant fails to compile until classified
726    pub fn is_blocking(&self) -> bool {
727        match self {
728            // A symbol that exists at a later tag is adaptable: land
729            // the dep pin bump first.
730            Self::MissingSymbol {
731                first_seen_at_tag: Some(_),
732                ..
733            } => false,
734            Self::ArityChanged {
735                expected_available_at: Some(_),
736                ..
737            } => false,
738            Self::MissingSymbol { .. }
739            | Self::ArityChanged { .. }
740            | Self::SignatureChanged { .. }
741            | Self::FileAbsent { .. }
742            | Self::NowHidden { .. }
743            | Self::RecordFieldsChanged { .. }
744            | Self::UntrackedModuleMissing { .. }
745            | Self::ClauseMismatch { .. }
746            | Self::MissingPrereq { .. }
747            | Self::SyntacticArtifact { .. }
748            | Self::BehaviourCallbackSignatureChanged { .. }
749            | Self::BehaviourCallbackRemoved { .. }
750            | Self::BehaviourCallbackAdded { .. }
751            | Self::WireConstantChanged { .. }
752            | Self::HistoricalImplementationMissing { .. }
753            | Self::WireContractRegression { .. }
754            | Self::ReturnShapeMismatch { .. } => true,
755            Self::ContextDrift { .. }
756            | Self::PreimageDrifted { .. }
757            | Self::PreimageMissing { .. }
758            | Self::PostimageCollision { .. }
759            | Self::DeprecatedUsage { .. }
760            | Self::UnsupportedFileType { .. }
761            | Self::ModuleRelocated { .. }
762            | Self::WireContractBodyDrift { .. }
763            | Self::MissingType { .. }
764            | Self::PathRename { .. }
765            | Self::TargetPathAbsent { .. }
766            | Self::TestModuleSymbolMissing { .. }
767            | Self::BehaviourModuleMissing { .. }
768            | Self::HeaderFileMissing { .. }
769            | Self::MacroUndefinedOnTarget { .. }
770            | Self::RecordUndefinedOnTarget { .. }
771            | Self::LocalCallUndefinedOnTarget { .. }
772            | Self::ExportedTypeUndefinedOnTarget { .. }
773            | Self::QualifiedCallUndefinedOnTarget { .. }
774            | Self::IndirectCallUndefinedOnTarget { .. }
775            | Self::QualifiedCallReturnShapeDrift { .. }
776            | Self::LocalCallReturnShapeDrift { .. }
777            | Self::MacroValueDrift { .. }
778            | Self::BehaviourCallbackAddedOnTarget { .. }
779            | Self::BehaviourCallbackDriftOnTarget { .. }
780            | Self::VersionedMachineSnapshotMissing { .. }
781            | Self::WireConstantBindingsMissing { .. } => false,
782        }
783    }
784
785    /// True for reasons that are facts about *files* rather than the
786    /// pin's API surface. These are the only reasons path routing may
787    /// drop for a pin that owns none of the touched paths.
788    /// Wildcard-free on purpose, like `is_blocking`.
789    pub fn is_path_scoped(&self) -> bool {
790        match self {
791            Self::FileAbsent { .. }
792            | Self::ModuleRelocated { .. }
793            | Self::PreimageDrifted { .. }
794            | Self::PreimageMissing { .. }
795            | Self::PostimageCollision { .. }
796            | Self::ContextDrift { .. }
797            | Self::PathRename { .. }
798            | Self::TargetPathAbsent { .. }
799            | Self::UnsupportedFileType { .. }
800            | Self::SyntacticArtifact { .. } => true,
801            Self::MissingSymbol { .. }
802            | Self::ArityChanged { .. }
803            | Self::SignatureChanged { .. }
804            | Self::DeprecatedUsage { .. }
805            | Self::NowHidden { .. }
806            | Self::RecordFieldsChanged { .. }
807            | Self::UntrackedModuleMissing { .. }
808            | Self::ClauseMismatch { .. }
809            | Self::MissingPrereq { .. }
810            | Self::BehaviourCallbackSignatureChanged { .. }
811            | Self::BehaviourCallbackRemoved { .. }
812            | Self::BehaviourCallbackAdded { .. }
813            | Self::WireConstantChanged { .. }
814            | Self::HistoricalImplementationMissing { .. }
815            | Self::WireContractBodyDrift { .. }
816            | Self::WireContractRegression { .. }
817            | Self::ReturnShapeMismatch { .. }
818            | Self::MissingType { .. }
819            | Self::TestModuleSymbolMissing { .. }
820            | Self::BehaviourModuleMissing { .. }
821            | Self::HeaderFileMissing { .. }
822            | Self::MacroUndefinedOnTarget { .. }
823            | Self::RecordUndefinedOnTarget { .. }
824            | Self::LocalCallUndefinedOnTarget { .. }
825            | Self::ExportedTypeUndefinedOnTarget { .. }
826            | Self::QualifiedCallUndefinedOnTarget { .. }
827            | Self::IndirectCallUndefinedOnTarget { .. }
828            | Self::QualifiedCallReturnShapeDrift { .. }
829            | Self::LocalCallReturnShapeDrift { .. }
830            | Self::MacroValueDrift { .. }
831            | Self::BehaviourCallbackAddedOnTarget { .. }
832            | Self::BehaviourCallbackDriftOnTarget { .. }
833            | Self::VersionedMachineSnapshotMissing { .. }
834            | Self::WireConstantBindingsMissing { .. } => false,
835        }
836    }
837}
838
839/// The minimum pin that satisfies a missing-symbol reason: the typed
840/// ordering payload q-port turns into a "bump must land first" edge.
841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
842#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
843pub struct NeedsPin {
844    pub project: ProjectName,
845    pub tag: TagName,
846}