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    ApplicationName, Arity, CommitSha, FieldName, FunctionName, GitRef, MacroName, MapKey,
15    ModuleName, ProjectName, RecordName, RelativePath, SchemaFeature, TagName, TypeName,
16    vocabulary,
17};
18use crate::model::resolver_coverage::ResolverClass;
19use crate::model::symbol::SymbolRef;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23#[serde(tag = "verdict", rename_all = "snake_case")]
24pub enum Verdict {
25    Compatible,
26    RequiresAdaptation { reasons: Vec<Reason> },
27    Incompatible { reasons: Vec<Reason> },
28    Inapplicable { reason: InapplicableReason },
29}
30
31impl Verdict {
32    pub fn from_reasons(reasons: Vec<Reason>) -> Self {
33        if reasons.is_empty() {
34            return Self::Compatible;
35        }
36        if reasons.iter().any(Reason::is_blocking) {
37            Self::Incompatible { reasons }
38        } else {
39            Self::RequiresAdaptation { reasons }
40        }
41    }
42
43    pub fn is_compatible(&self) -> bool {
44        matches!(self, Self::Compatible)
45    }
46
47    pub fn reasons(&self) -> &[Reason] {
48        match self {
49            Self::Compatible | Self::Inapplicable { .. } => &[],
50            Self::RequiresAdaptation { reasons } | Self::Incompatible { reasons } => reasons,
51        }
52    }
53}
54
55#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum Reason {
60    MissingSymbol {
61        symbol: SymbolRef,
62        /// Earliest snapshot tag strictly later than the pin where the
63        /// symbol appears. `Some` reclassifies the reason as
64        /// adaptable: land the dep pin bump first.
65        first_seen_at_tag: Option<TagName>,
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        needs_pin_at_least: Option<NeedsPin>,
68        suggested_replacement: Option<SymbolRef>,
69    },
70    ArityChanged {
71        module: ModuleName,
72        function: FunctionName,
73        expected: Arity,
74        found: Vec<Arity>,
75        /// Earliest snapshot tag strictly later than the pin exporting
76        /// the expected arity. Same bump-first semantics as
77        /// `MissingSymbol::first_seen_at_tag`.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        expected_available_at: Option<TagName>,
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        needs_pin_at_least: Option<NeedsPin>,
82    },
83    SignatureChanged {
84        module: ModuleName,
85        function: FunctionName,
86        arity: Arity,
87        expected_spec: String,
88        found_spec: String,
89    },
90    FileAbsent {
91        path: PathBuf,
92    },
93    ContextDrift {
94        path: PathBuf,
95        hunk_index: usize,
96    },
97    /// The contiguous preimage block (Context and Removed lines from
98    /// the hunk) was found verbatim at a different starting line.
99    /// Cherry-pick adapters handle this with the `-x` trailer; the
100    /// operator just needs the delta to confirm the patch's intent
101    /// still applies.
102    PreimageDrifted {
103        path: PathBuf,
104        hunk_index: usize,
105        line_delta: isize,
106    },
107    /// The hunk's preimage matches, but its added lines land at a file
108    /// edge where the target already has divergent content the base
109    /// lacked: an add/add a contiguous preimage match cannot detect. The
110    /// 3-way cherry-pick conflicts where the preimage-only check reads
111    /// clean. Non-blocking: the operator resolves the overlap.
112    PostimageCollision {
113        path: PathBuf,
114        hunk_index: usize,
115    },
116    /// The preimage block does not appear anywhere in the file. The
117    /// patch was authored against a tree where the lines existed; on
118    /// this pin they don't. Structurally inapplicable: the operator
119    /// needs an excerpt to reason about why.
120    PreimageMissing {
121        path: PathBuf,
122        hunk_index: usize,
123        preimage_excerpt: String,
124    },
125    DeprecatedUsage {
126        symbol: SymbolRef,
127        since: Option<TagName>,
128        replacement: Option<SymbolRef>,
129    },
130    NowHidden {
131        module: ModuleName,
132    },
133    RecordFieldsChanged {
134        record: RecordName,
135        expected: Vec<FieldName>,
136        found: Vec<FieldName>,
137    },
138    UnsupportedFileType {
139        path: PathBuf,
140    },
141    UntrackedModuleMissing {
142        module: ModuleName,
143    },
144    /// Call-site argument shapes don't satisfy any clause head at the
145    /// pin. Emitted when both the call and the pin's clause patterns
146    /// are concrete enough to compare; `Unknown` on either side
147    /// suppresses the reason.
148    ClauseMismatch {
149        module: ModuleName,
150        function: FunctionName,
151        arity: Arity,
152        call_args: Vec<ArgShape>,
153        pin_clauses: Vec<Vec<ArgShape>>,
154    },
155    /// Patch references an MFA owned by the self-project but absent from the
156    /// resolved self-snapshot. `suggested_source_for_prereq` is filled only
157    /// with `--suggest-prereqs`.
158    MissingPrereq {
159        symbol: SymbolRef,
160        self_branch: GitRef,
161        suggested_source_for_prereq: Option<CommitSha>,
162    },
163    /// Pre-flight syntactic damage in a touched file: an unresolved
164    /// merge-conflict marker, an exported function with no definition, etc.
165    SyntacticArtifact {
166        path: PathBuf,
167        line: usize,
168        artifact: ArtifactKind,
169    },
170    /// Patch changes a `-callback` signature; an implementer still has the old one.
171    BehaviourCallbackSignatureChanged {
172        behaviour: ModuleName,
173        callback: FunctionName,
174        arity: Arity,
175        expected_after_patch: String,
176        implementer: ModuleName,
177        implementer_signature: String,
178    },
179    /// Patch removes a required callback; implementer still exports it.
180    BehaviourCallbackRemoved {
181        behaviour: ModuleName,
182        callback: FunctionName,
183        arity: Arity,
184        implementer: ModuleName,
185    },
186    /// Patch adds a required callback; implementer is missing the export.
187    BehaviourCallbackAdded {
188        behaviour: ModuleName,
189        callback: FunctionName,
190        arity: Arity,
191        implementer: ModuleName,
192    },
193    /// Patch touches a path absent on pin, but a module of the same basename exists elsewhere.
194    ModuleRelocated {
195        module: ModuleName,
196        patch_path: PathBuf,
197    },
198    /// Wire-bearing macro value differs between source and pin.
199    WireConstantChanged {
200        module: ModuleName,
201        macro_name: String,
202        before: String,
203        after: String,
204    },
205    /// `version/0` advanced without the matching historical-implementation module.
206    HistoricalImplementationMissing {
207        module: ModuleName,
208        advertised_version_before: u32,
209        advertised_version_after: u32,
210        expected_historical_module: ModuleName,
211    },
212    /// Function bodies drifted at the same advertised `version/0`. Mixed-version cluster risk.
213    WireContractBodyDrift {
214        module: ModuleName,
215        functions: Vec<FunctionName>,
216        advertised_version: u32,
217    },
218    /// `version/0` decreased pin → patch.
219    WireContractRegression {
220        module: ModuleName,
221        pin_version: u32,
222        patch_version: u32,
223    },
224    /// Spec return shape differs between source and pin for a referenced MFA.
225    ReturnShapeMismatch {
226        module: ModuleName,
227        function: FunctionName,
228        arity: Arity,
229        source_signature: String,
230        pin_signature: String,
231    },
232    /// A type-reference inside a `-spec`, `-callback`, `-type`, or
233    /// `-opaque` attribute resolves to a name and arity that the pin
234    /// does not expose via `-export_type`. Non-blocking: a missing
235    /// type alias is a dialyzer-level concern, not a load-time crash.
236    MissingType {
237        module: ModuleName,
238        name: TypeName,
239        arity: Arity,
240    },
241    /// A touched path is absent from the target tree, but a
242    /// configured `[[path_translation]]` rewrites it to a path that
243    /// does exist on the target. Non-blocking: the operator (or the
244    /// cherry-pick driver) rewrites the path before applying.
245    PathRename {
246        source_path: PathBuf,
247        target_path: PathBuf,
248        translation: TranslationSource,
249    },
250    /// A touched path is absent on the target while others are present
251    /// and no `[[path_translation]]` rewrites it. Non-blocking: dropping
252    /// the hunks for this path lets the rest apply. The all-absent case
253    /// is `InapplicableReason::PathsMissingOnTarget`.
254    TargetPathAbsent {
255        path: RelativePath,
256    },
257    /// A `_SUITE.erl` file in the patch references `missing_module:f/n`
258    /// where `missing_module` resolves to no source file under any
259    /// configured `FamilyDefaults.test_helper_search_paths` on the
260    /// target tree. The cherry-pick compiles and the SUITE may even
261    /// load, but every call to the missing helper crashes at runtime:
262    /// the failure pattern seen in the Jun 2026 v4.1.x → v4.0.x round
263    /// (the `amqp_utils:connection_config/1` undef in
264    /// `amqp10_connection_max_SUITE`). Non-blocking by default: the
265    /// diagnostic equivalent (`Diagnostics.missing_test_modules`)
266    /// always fires; the `Reason` variant fires only when
267    /// `--target-repo-dir-path` is supplied and the resolution is
268    /// authoritative.
269    TestModuleSymbolMissing {
270        suite_path: RelativePath,
271        missing_module: ModuleName,
272        call_sites: Vec<TestCallSite>,
273    },
274    /// A touched `.erl` declares `-behaviour(behaviour)` (or
275    /// `-behavior(...)`) where `behaviour` resolves to no source
276    /// module on the target tree. `erlc` compiles with a warning,
277    /// then hook dispatch crashes at runtime. The `BehaviourCallback*`
278    /// variants model implementer drift; this one catches the
279    /// missing-behaviour-module case they 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. Same check as `MacroUndefinedOnTarget`
308    /// for 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    /// same check for the function namespace; 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 form 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`. Same comparison as `ReturnShapeMismatch` on
342    /// the 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-call form of
358    /// `QualifiedCallReturnShapeDrift`; imported resolutions are
359    /// withheld, since the callee's spec is 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    /// A touched implementer of a family-declared dependency behaviour
448    /// (`FamilyDefaults.dep_behaviours`) exports a callback the pinned
449    /// behaviour does not declare. The callback compiles but is never
450    /// invoked at this pin. Non-blocking: the operator judges whether
451    /// the backport still does anything useful without it.
452    BehaviourCallbackUnknownOnPin {
453        behaviour: ModuleName,
454        callback: FunctionName,
455        arity: Arity,
456        pin_arities: Vec<Arity>,
457        implementer: ModuleName,
458        evidence: DriftEvidence,
459    },
460    /// An added implementer of a family-declared dependency behaviour
461    /// lacks a callback the pinned behaviour requires. Blocking: the
462    /// missing callback crashes the first time the behaviour invokes it.
463    BehaviourCallbackMissingOnPin {
464        behaviour: ModuleName,
465        callback: FunctionName,
466        arity: Arity,
467        implementer: ModuleName,
468    },
469    /// An added line uses a map key that a family-declared option type
470    /// (`FamilyDefaults.option_types`) gained after the pinned version.
471    /// Non-blocking: an unrecognized key is typically ignored by the
472    /// older dependency rather than rejected.
473    OptionKeyUnknownOnPin {
474        project: ProjectName,
475        module: ModuleName,
476        type_name: TypeName,
477        key: MapKey,
478        pin_tag: TagName,
479    },
480    /// The patch adds a test suite to an app whose Makefile dispatches
481    /// suites by explicit registration (`FamilyDefaults.suite_registration`)
482    /// without registering it there. Non-blocking: the suite still
483    /// compiles, it just never runs under CI.
484    SuiteNotRegisteredForCt {
485        suite_path: RelativePath,
486        makefile_path: RelativePath,
487    },
488    /// An introduced cuttlefish schema mapping uses an attribute the
489    /// pinned cuttlefish version does not support. Blocking: the
490    /// corpus evidence for this shape is a boot failure.
491    SchemaFeatureUnsupportedOnPin {
492        schema_path: RelativePath,
493        conf_key: Option<String>,
494        feature: SchemaFeature,
495        required_version: String,
496        pinned_version: String,
497    },
498    /// An introduced cuttlefish schema mapping targets an application
499    /// environment key that nothing in the target tree's owning apps
500    /// reads. Non-blocking: the token search is deliberately crude, so
501    /// the operator reviews before trusting a positive.
502    SchemaKeyReaderMissing {
503        schema_path: RelativePath,
504        target_key: String,
505        searched_apps: Vec<ApplicationName>,
506        /// Apps the key names that do not exist on the target tree at
507        /// all: a stronger form of the same finding.
508        absent_apps: Vec<ApplicationName>,
509    },
510}
511
512/// How a dependency-behaviour drift finding was established. Wire-visible
513/// so consumers can weigh confidence: a set-difference finding is exact,
514/// a name-anchored one is heuristic.
515#[non_exhaustive]
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
517#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
518#[serde(rename_all = "snake_case")]
519pub enum DriftEvidence {
520    /// Present in the source pin's callback set, absent from the
521    /// target pin's.
522    SourceSideSetDifference,
523    /// Callback-name collision at an undeclared arity, with no source
524    /// pin to confirm it.
525    NameAnchored,
526}
527
528/// Which side of a snapshot comparison the data is missing from.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
530#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
531#[serde(rename_all = "snake_case")]
532pub enum SnapshotSide {
533    Source,
534    Target,
535    Both,
536}
537
538/// One `Module:Function/Arity` reference inside a `_SUITE.erl` that
539/// resolves to a module absent on the target tree. Carries the line
540/// number so the operator can jump straight to the call site.
541#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543pub struct TestCallSite {
544    pub function: FunctionName,
545    pub arity: Arity,
546    pub line: u32,
547}
548
549/// `-include` and `-include_lib` are syntactically distinct: the
550/// former takes a relative-to-source path, the latter an
551/// `application/include/file.hrl` shape resolved through OTP's lib
552/// path. The variant preserves which form the source used so the
553/// remediation text can match.
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
556#[serde(tag = "form", rename_all = "snake_case")]
557pub enum IncludeDirective {
558    Include { path: String },
559    IncludeLib { path: String },
560}
561
562impl IncludeDirective {
563    pub fn path(&self) -> &str {
564        match self {
565            Self::Include { path } | Self::IncludeLib { path } => path,
566        }
567    }
568}
569
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
572#[serde(tag = "kind", rename_all = "snake_case")]
573pub enum TranslationSource {
574    ConfigStanza { name: String },
575    ExternalFile { path: PathBuf, name: String },
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
580#[serde(tag = "artifact_kind", rename_all = "snake_case")]
581pub enum ArtifactKind {
582    /// `<<<<<<<`, `=======`, `>>>>>>>`, or `||||||| ` marker present in a
583    /// touched file. The cherry-pick driver left a conflict unresolved.
584    ConflictMarker { marker: ConflictMarker },
585    /// `-export([f/N])` line without a matching clause defining `f/N`.
586    /// Compiles, then crashes at runtime on the first call.
587    ExportWithoutBody {
588        module: ModuleName,
589        function: FunctionName,
590        arity: Arity,
591    },
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
595#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
596#[serde(rename_all = "snake_case")]
597pub enum ConflictMarker {
598    /// `<<<<<<<` line: start of the local side.
599    Ours,
600    /// `=======` line: divider between sides.
601    Divider,
602    /// `>>>>>>>` line: end of the incoming side.
603    Theirs,
604    /// `||||||| ` line: base ancestor marker from a 3-way diff.
605    Ancestor,
606}
607
608impl ConflictMarker {
609    /// Returns the marker variant when `line` starts with at least seven
610    /// of the same `git` conflict-marker token followed by whitespace or
611    /// end of line. Returns `None` otherwise.
612    ///
613    /// `git`'s default marker length is seven; `merge.conflictStyle.markerSize`
614    /// can grow it, so the check accepts seven or more.
615    pub fn detect(line: &str) -> Option<Self> {
616        Self::detect_with_marker(line, '<', Self::Ours)
617            .or_else(|| Self::detect_with_marker(line, '=', Self::Divider))
618            .or_else(|| Self::detect_with_marker(line, '>', Self::Theirs))
619            .or_else(|| Self::detect_with_marker(line, '|', Self::Ancestor))
620    }
621
622    fn detect_with_marker(line: &str, token: char, marker: Self) -> Option<Self> {
623        const MIN_RUN: usize = 7;
624        let mut count = 0usize;
625        for c in line.chars() {
626            if c == token {
627                count += 1;
628                continue;
629            }
630            return if count >= MIN_RUN && (c == ' ' || c == '\t') {
631                Some(marker)
632            } else {
633                None
634            };
635        }
636        if count >= MIN_RUN { Some(marker) } else { None }
637    }
638}
639
640vocabulary!(
641    /// The apply outcomes a divergence predictor consumes, ordered by
642    /// severity: a higher variant outranks a lower one on the same path, so
643    /// dedup to one conflict per path is a `max`.
644    #[derive(PartialOrd, Ord)]
645    pub enum ApplyConflictKind: "apply conflict kind" {
646        PostimageCollision => "postimage_collision",
647        PreimageMissing => "preimage_missing",
648        FileAbsent => "file_absent",
649    }
650);
651
652/// Which recognized call form carried an indirect MFA reference. Names
653/// the innermost form: a meck expectation reached through an rpc helper
654/// is `MeckExpect`, not the helper.
655#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
656#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
657#[serde(rename_all = "snake_case")]
658pub enum IndirectCallForm {
659    MeckExpect,
660    RpcCall,
661    ErpcCall,
662    CtBrokerHelperRpc,
663    RabbitMiscRpcCall,
664}
665
666impl IndirectCallForm {
667    /// The Erlang form the extractor matched, for text output. Distinct
668    /// from the serde wire name on purpose.
669    #[must_use]
670    pub fn display_form(self) -> &'static str {
671        match self {
672            Self::MeckExpect => "meck:expect",
673            Self::RpcCall => "rpc:call",
674            Self::ErpcCall => "erpc:call",
675            Self::CtBrokerHelperRpc => "rabbit_ct_broker_helpers:rpc",
676            Self::RabbitMiscRpcCall => "rabbit_misc:rpc_call",
677        }
678    }
679}
680
681impl Reason {
682    /// The risky apply outcome and its path, or `None` for a clean or
683    /// non-apply reason. `PreimageDrifted` is excluded: a drifted preimage
684    /// applies cleanly. Wildcard-free so a future apply reason must
685    /// declare its risk here, not default silently to clean.
686    pub fn apply_conflict(&self) -> Option<(&Path, ApplyConflictKind)> {
687        match self {
688            Self::PostimageCollision { path, .. } => {
689                Some((path, ApplyConflictKind::PostimageCollision))
690            }
691            Self::PreimageMissing { path, .. } => Some((path, ApplyConflictKind::PreimageMissing)),
692            Self::FileAbsent { path } => Some((path, ApplyConflictKind::FileAbsent)),
693            Self::MissingSymbol { .. }
694            | Self::ArityChanged { .. }
695            | Self::SignatureChanged { .. }
696            | Self::ContextDrift { .. }
697            | Self::PreimageDrifted { .. }
698            | Self::DeprecatedUsage { .. }
699            | Self::NowHidden { .. }
700            | Self::RecordFieldsChanged { .. }
701            | Self::UnsupportedFileType { .. }
702            | Self::UntrackedModuleMissing { .. }
703            | Self::ClauseMismatch { .. }
704            | Self::MissingPrereq { .. }
705            | Self::SyntacticArtifact { .. }
706            | Self::BehaviourCallbackSignatureChanged { .. }
707            | Self::BehaviourCallbackRemoved { .. }
708            | Self::BehaviourCallbackAdded { .. }
709            | Self::ModuleRelocated { .. }
710            | Self::WireConstantChanged { .. }
711            | Self::HistoricalImplementationMissing { .. }
712            | Self::WireContractBodyDrift { .. }
713            | Self::WireContractRegression { .. }
714            | Self::ReturnShapeMismatch { .. }
715            | Self::MissingType { .. }
716            | Self::PathRename { .. }
717            | Self::TargetPathAbsent { .. }
718            | Self::TestModuleSymbolMissing { .. }
719            | Self::BehaviourModuleMissing { .. }
720            | Self::HeaderFileMissing { .. }
721            | Self::MacroUndefinedOnTarget { .. }
722            | Self::RecordUndefinedOnTarget { .. }
723            | Self::LocalCallUndefinedOnTarget { .. }
724            | Self::ExportedTypeUndefinedOnTarget { .. }
725            | Self::QualifiedCallUndefinedOnTarget { .. }
726            | Self::IndirectCallUndefinedOnTarget { .. }
727            | Self::QualifiedCallReturnShapeDrift { .. }
728            | Self::LocalCallReturnShapeDrift { .. }
729            | Self::MacroValueDrift { .. }
730            | Self::BehaviourCallbackAddedOnTarget { .. }
731            | Self::BehaviourCallbackDriftOnTarget { .. }
732            | Self::VersionedMachineSnapshotMissing { .. }
733            | Self::WireConstantBindingsMissing { .. }
734            | Self::BehaviourCallbackUnknownOnPin { .. }
735            | Self::BehaviourCallbackMissingOnPin { .. }
736            | Self::OptionKeyUnknownOnPin { .. }
737            | Self::SuiteNotRegisteredForCt { .. }
738            | Self::SchemaFeatureUnsupportedOnPin { .. }
739            | Self::SchemaKeyReaderMissing { .. } => None,
740        }
741    }
742
743    /// The class this reason flagged, for a predicted-class breakdown:
744    /// the absent-symbol family only. Not the corpus break class, which
745    /// is the symbol that actually broke the build (the observed outcome).
746    pub fn resolver_class(&self) -> Option<ResolverClass> {
747        match self {
748            Self::MissingSymbol { symbol, .. } | Self::MissingPrereq { symbol, .. } => {
749                Some(ResolverClass::of_symbol_kind(&symbol.kind))
750            }
751            Self::MissingType { .. } => Some(ResolverClass::Type),
752            Self::MacroUndefinedOnTarget { .. } | Self::MacroValueDrift { .. } => {
753                Some(ResolverClass::Macro)
754            }
755            Self::RecordUndefinedOnTarget { .. } => Some(ResolverClass::Record),
756            Self::ExportedTypeUndefinedOnTarget { .. } => Some(ResolverClass::Type),
757            Self::LocalCallUndefinedOnTarget { .. } | Self::LocalCallReturnShapeDrift { .. } => {
758                Some(ResolverClass::LocalCall)
759            }
760            Self::QualifiedCallUndefinedOnTarget { .. }
761            | Self::QualifiedCallReturnShapeDrift { .. } => Some(ResolverClass::QualifiedCall),
762            Self::IndirectCallUndefinedOnTarget { .. } => Some(ResolverClass::IndirectCall),
763            Self::HeaderFileMissing { .. } => Some(ResolverClass::Include),
764            Self::BehaviourModuleMissing { .. }
765            | Self::BehaviourCallbackAddedOnTarget { .. }
766            | Self::BehaviourCallbackDriftOnTarget { .. } => Some(ResolverClass::Behaviour),
767            Self::ArityChanged { .. }
768            | Self::SignatureChanged { .. }
769            | Self::FileAbsent { .. }
770            | Self::ContextDrift { .. }
771            | Self::PreimageDrifted { .. }
772            | Self::PostimageCollision { .. }
773            | Self::PreimageMissing { .. }
774            | Self::DeprecatedUsage { .. }
775            | Self::NowHidden { .. }
776            | Self::RecordFieldsChanged { .. }
777            | Self::UnsupportedFileType { .. }
778            | Self::UntrackedModuleMissing { .. }
779            | Self::ClauseMismatch { .. }
780            | Self::SyntacticArtifact { .. }
781            | Self::BehaviourCallbackSignatureChanged { .. }
782            | Self::BehaviourCallbackRemoved { .. }
783            | Self::BehaviourCallbackAdded { .. }
784            | Self::ModuleRelocated { .. }
785            | Self::WireConstantChanged { .. }
786            | Self::HistoricalImplementationMissing { .. }
787            | Self::WireContractBodyDrift { .. }
788            | Self::WireContractRegression { .. }
789            | Self::ReturnShapeMismatch { .. }
790            | Self::PathRename { .. }
791            | Self::TargetPathAbsent { .. }
792            | Self::TestModuleSymbolMissing { .. }
793            | Self::VersionedMachineSnapshotMissing { .. }
794            | Self::WireConstantBindingsMissing { .. }
795            | Self::BehaviourCallbackUnknownOnPin { .. }
796            | Self::BehaviourCallbackMissingOnPin { .. }
797            | Self::OptionKeyUnknownOnPin { .. }
798            | Self::SuiteNotRegisteredForCt { .. }
799            | Self::SchemaFeatureUnsupportedOnPin { .. }
800            | Self::SchemaKeyReaderMissing { .. } => None,
801        }
802    }
803
804    // wildcard-free so a new variant fails to compile until classified
805    pub fn is_blocking(&self) -> bool {
806        match self {
807            // exists at a later tag: adaptable once the dep pin bump lands
808            Self::MissingSymbol {
809                first_seen_at_tag: Some(_),
810                ..
811            } => false,
812            Self::ArityChanged {
813                expected_available_at: Some(_),
814                ..
815            } => false,
816            Self::MissingSymbol { .. }
817            | Self::ArityChanged { .. }
818            | Self::SignatureChanged { .. }
819            | Self::FileAbsent { .. }
820            | Self::RecordFieldsChanged { .. }
821            | Self::UntrackedModuleMissing { .. }
822            | Self::ClauseMismatch { .. }
823            | Self::MissingPrereq { .. }
824            | Self::SyntacticArtifact { .. }
825            | Self::BehaviourCallbackSignatureChanged { .. }
826            | Self::BehaviourCallbackRemoved { .. }
827            | Self::BehaviourCallbackAdded { .. }
828            | Self::WireConstantChanged { .. }
829            | Self::HistoricalImplementationMissing { .. }
830            | Self::WireContractRegression { .. }
831            | Self::ReturnShapeMismatch { .. }
832            | Self::BehaviourCallbackMissingOnPin { .. }
833            | Self::SchemaFeatureUnsupportedOnPin { .. } => true,
834            // a hidden module still loads and its exports still resolve
835            Self::NowHidden { .. }
836            | Self::ContextDrift { .. }
837            | Self::PreimageDrifted { .. }
838            | Self::PreimageMissing { .. }
839            | Self::PostimageCollision { .. }
840            | Self::DeprecatedUsage { .. }
841            | Self::UnsupportedFileType { .. }
842            | Self::ModuleRelocated { .. }
843            | Self::WireContractBodyDrift { .. }
844            | Self::MissingType { .. }
845            | Self::PathRename { .. }
846            | Self::TargetPathAbsent { .. }
847            | Self::TestModuleSymbolMissing { .. }
848            | Self::BehaviourModuleMissing { .. }
849            | Self::HeaderFileMissing { .. }
850            | Self::MacroUndefinedOnTarget { .. }
851            | Self::RecordUndefinedOnTarget { .. }
852            | Self::LocalCallUndefinedOnTarget { .. }
853            | Self::ExportedTypeUndefinedOnTarget { .. }
854            | Self::QualifiedCallUndefinedOnTarget { .. }
855            | Self::IndirectCallUndefinedOnTarget { .. }
856            | Self::QualifiedCallReturnShapeDrift { .. }
857            | Self::LocalCallReturnShapeDrift { .. }
858            | Self::MacroValueDrift { .. }
859            | Self::BehaviourCallbackAddedOnTarget { .. }
860            | Self::BehaviourCallbackDriftOnTarget { .. }
861            | Self::VersionedMachineSnapshotMissing { .. }
862            | Self::WireConstantBindingsMissing { .. }
863            | Self::BehaviourCallbackUnknownOnPin { .. }
864            | Self::OptionKeyUnknownOnPin { .. }
865            | Self::SuiteNotRegisteredForCt { .. }
866            | Self::SchemaKeyReaderMissing { .. } => false,
867        }
868    }
869
870    /// True for reasons that are facts about *files* rather than the
871    /// pin's API surface. These are the only reasons path routing may
872    /// drop for a pin that owns none of the touched paths.
873    /// Wildcard-free on purpose, like `is_blocking`.
874    pub fn is_path_scoped(&self) -> bool {
875        match self {
876            Self::FileAbsent { .. }
877            | Self::ModuleRelocated { .. }
878            | Self::PreimageDrifted { .. }
879            | Self::PreimageMissing { .. }
880            | Self::PostimageCollision { .. }
881            | Self::ContextDrift { .. }
882            | Self::PathRename { .. }
883            | Self::TargetPathAbsent { .. }
884            | Self::UnsupportedFileType { .. }
885            | Self::SyntacticArtifact { .. } => true,
886            Self::MissingSymbol { .. }
887            | Self::ArityChanged { .. }
888            | Self::SignatureChanged { .. }
889            | Self::DeprecatedUsage { .. }
890            | Self::NowHidden { .. }
891            | Self::RecordFieldsChanged { .. }
892            | Self::UntrackedModuleMissing { .. }
893            | Self::ClauseMismatch { .. }
894            | Self::MissingPrereq { .. }
895            | Self::BehaviourCallbackSignatureChanged { .. }
896            | Self::BehaviourCallbackRemoved { .. }
897            | Self::BehaviourCallbackAdded { .. }
898            | Self::WireConstantChanged { .. }
899            | Self::HistoricalImplementationMissing { .. }
900            | Self::WireContractBodyDrift { .. }
901            | Self::WireContractRegression { .. }
902            | Self::ReturnShapeMismatch { .. }
903            | Self::MissingType { .. }
904            | Self::TestModuleSymbolMissing { .. }
905            | Self::BehaviourModuleMissing { .. }
906            | Self::HeaderFileMissing { .. }
907            | Self::MacroUndefinedOnTarget { .. }
908            | Self::RecordUndefinedOnTarget { .. }
909            | Self::LocalCallUndefinedOnTarget { .. }
910            | Self::ExportedTypeUndefinedOnTarget { .. }
911            | Self::QualifiedCallUndefinedOnTarget { .. }
912            | Self::IndirectCallUndefinedOnTarget { .. }
913            | Self::QualifiedCallReturnShapeDrift { .. }
914            | Self::LocalCallReturnShapeDrift { .. }
915            | Self::MacroValueDrift { .. }
916            | Self::BehaviourCallbackAddedOnTarget { .. }
917            | Self::BehaviourCallbackDriftOnTarget { .. }
918            | Self::VersionedMachineSnapshotMissing { .. }
919            | Self::WireConstantBindingsMissing { .. }
920            | Self::BehaviourCallbackUnknownOnPin { .. }
921            | Self::BehaviourCallbackMissingOnPin { .. }
922            | Self::OptionKeyUnknownOnPin { .. }
923            | Self::SuiteNotRegisteredForCt { .. }
924            | Self::SchemaFeatureUnsupportedOnPin { .. }
925            | Self::SchemaKeyReaderMissing { .. } => false,
926        }
927    }
928}
929
930/// The minimum pin that satisfies a missing-symbol reason: the typed
931/// ordering payload q-port turns into a "bump must land first" edge.
932#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
933#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
934pub struct NeedsPin {
935    pub project: ProjectName,
936    pub tag: TagName,
937}