Skip to main content

backhopper_core/compat/
patch.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//! `Patch<S>` typestate pipeline plus its public data types.
6//!
7//! The unified-diff parser is in `compat::diff`; the per-pin evaluator
8//! is in `compat::evaluate`. This module is the entry point and the
9//! type-state glue.
10
11use std::collections::{BTreeMap, BTreeSet, HashSet};
12use std::marker::PhantomData;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use crate::compat::arg_shape::ArgShape;
17use crate::compat::call_sites::{DynamicCall, scan_hunk};
18use crate::compat::diff;
19use crate::compat::evaluate::{AnalyzedInputs, PinEvalOptions, PostimageTally, evaluate_pin};
20use crate::compat::patch_facts::{
21    classify as classify_patch_facts, is_only_test_visibility_change, suggest_suites,
22};
23use crate::compat::scope::{PinScope, UntrackedTally};
24use crate::config::FamilyDefaults;
25use crate::erlang_macros::MacroTable;
26use crate::errors::PatchError;
27use crate::model::names::{Mfa, ModuleName, ProjectName, RecordName, RelativePath};
28use crate::model::pin::Pin;
29use crate::model::snapshot::{Snapshot, state};
30use crate::model::symbol::{RefOrigin, SymbolKind, SymbolRef};
31use crate::model::verdict::{
32    AlreadyPresent, ContentPresence, Diagnostics, IndirectCallTally, MacroValueTally, PinVerdict,
33    SeriesEvaluation, SeriesVerdict, ShapeCheckTally, TargetAxisSlot, Unanalyzed,
34};
35
36pub use crate::compat::diff::PATCH_SIZE_LIMIT;
37
38pub mod patch_state {
39    mod private {
40        pub trait Sealed {}
41    }
42
43    /// Sealed so a foreign marker cannot stand in for `Raw` or `Analyzed`.
44    pub trait PatchState: private::Sealed {}
45
46    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47    pub struct Raw;
48
49    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50    pub struct Analyzed;
51
52    impl private::Sealed for Raw {}
53    impl private::Sealed for Analyzed {}
54    impl PatchState for Raw {}
55    impl PatchState for Analyzed {}
56}
57
58pub use patch_state::{Analyzed, Raw};
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum SourceKind {
62    Erlang,
63    Elixir,
64    /// Cuttlefish `.schema`, `.snippets`, and `.partial` files: Erlang
65    /// fragments embedded in a config-key DSL. Locating the
66    /// `{translation, ..., fun(Conf) -> M:F(...) end}` fun bodies needs
67    /// the whole file and the cuttlefish parser, so the CLI extracts the
68    /// references and folds them in with `with_extra_references`. The
69    /// `.partial` extension was introduced in cuttlefish 3.8.0.
70    CuttlefishSchema,
71    Other,
72}
73
74impl SourceKind {
75    pub fn from_path(path: &str) -> Self {
76        if path.ends_with(".erl") || path.ends_with(".hrl") {
77            Self::Erlang
78        } else if path.ends_with(".ex") || path.ends_with(".exs") {
79            Self::Elixir
80        } else if path.ends_with(".schema")
81            || path.ends_with(".snippets")
82            || path.ends_with(".partial")
83        {
84            Self::CuttlefishSchema
85        } else {
86            Self::Other
87        }
88    }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct PatchedFile {
93    pub old_path: Option<PathBuf>,
94    pub new_path: Option<PathBuf>,
95    pub language: SourceKind,
96    pub binary: bool,
97    pub hunks: Vec<Hunk>,
98}
99
100impl PatchedFile {
101    /// New path if present, else old: the file's representative path, the
102    /// new-then-old precedence a rename is reported with.
103    pub fn primary_path(&self) -> Option<&Path> {
104        self.new_path.as_deref().or(self.old_path.as_deref())
105    }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Hunk {
110    pub old_start: usize,
111    pub old_count: usize,
112    pub new_start: usize,
113    pub new_count: usize,
114    pub lines: Vec<HunkLine>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum HunkLine {
119    Context(String),
120    Added(String),
121    Removed(String),
122}
123
124impl HunkLine {
125    pub fn is_addition(&self) -> bool {
126        matches!(self, Self::Added(_))
127    }
128
129    pub fn text(&self) -> &str {
130        match self {
131            Self::Context(s) | Self::Added(s) | Self::Removed(s) => s.as_str(),
132        }
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Patch<S: patch_state::PatchState = Raw> {
138    files: Vec<PatchedFile>,
139    referenced: Vec<SymbolRef>,
140    defined: Vec<SymbolRef>,
141    dynamic_calls: Vec<DynamicCall>,
142    unsupported_files: Vec<PathBuf>,
143    call_args: Vec<(Mfa, Vec<ArgShape>)>,
144    _state: PhantomData<S>,
145}
146
147impl<S: patch_state::PatchState> Patch<S> {
148    pub fn files(&self) -> &[PatchedFile] {
149        &self.files
150    }
151}
152
153impl Patch<Raw> {
154    pub fn parse(input: &[u8]) -> Result<Self, PatchError> {
155        let files = diff::parse(input)?;
156        Ok(Self {
157            files,
158            referenced: Vec::new(),
159            defined: Vec::new(),
160            dynamic_calls: Vec::new(),
161            unsupported_files: Vec::new(),
162            call_args: Vec::new(),
163            _state: PhantomData,
164        })
165    }
166
167    /// Run the per-file analyzer with no macro context. Equivalent to
168    /// `analyze_with_macros(&BTreeMap::new())`.
169    #[must_use]
170    pub fn analyze(self) -> Patch<Analyzed> {
171        self.analyze_with_macros(&BTreeMap::new())
172    }
173
174    /// Run the per-file analyzer. Each `PatchedFile` already carries a
175    /// `SourceKind` tag from the diff parser; the analyzer dispatches per
176    /// file. Erlang files run through the call-site extractor; Elixir
177    /// files are recorded as unsupported so the evaluator can emit
178    /// `Reason::UnsupportedFileType` rather than silently flagging the
179    /// patch as `Compatible`. Files with `SourceKind::Other` (build
180    /// scripts, markdown, etc.) are still silent-skipped.
181    ///
182    /// `macros_by_path` supplies a per-file macro table built from the
183    /// source pin's content (including resolved `-include` chains).
184    /// Calls hidden behind `?Name` and `?Name:f(args)` are resolved
185    /// against the matching file's table; apply-family BIFs use it to
186    /// resolve macro-module arguments.
187    #[must_use]
188    pub fn analyze_with_macros(
189        self,
190        macros_by_path: &BTreeMap<PathBuf, MacroTable>,
191    ) -> Patch<Analyzed> {
192        let mut referenced: Vec<SymbolRef> = Vec::new();
193        let mut defined: Vec<SymbolRef> = Vec::new();
194        let mut dynamic_calls: Vec<DynamicCall> = Vec::new();
195        let mut unsupported_files: Vec<PathBuf> = Vec::new();
196        let mut call_args: Vec<(Mfa, Vec<ArgShape>)> = Vec::new();
197        let empty = MacroTable::new();
198        for file in &self.files {
199            if file.binary {
200                continue;
201            }
202            let file_macros = file
203                .primary_path()
204                .and_then(|p| macros_by_path.get(p))
205                .unwrap_or(&empty);
206            match file.language {
207                SourceKind::Erlang => {
208                    for hunk in &file.hunks {
209                        let lines: Vec<(RefOrigin, &str)> = hunk
210                            .lines
211                            .iter()
212                            .filter_map(|line| match line {
213                                HunkLine::Added(text) => Some((RefOrigin::Added, text.as_str())),
214                                HunkLine::Context(text) => {
215                                    Some((RefOrigin::Context, text.as_str()))
216                                }
217                                HunkLine::Removed(_) => None,
218                            })
219                            .collect();
220                        let scan = scan_hunk(&lines, file_macros);
221                        referenced.extend(scan.referenced);
222                        defined.extend(scan.defined);
223                        dynamic_calls.extend(scan.dynamic_calls);
224                        call_args.extend(scan.call_args);
225                    }
226                }
227                // Schema files embed Erlang fun bodies in a config-key DSL; the
228                // cuttlefish parser sits outside this crate and injects their references.
229                SourceKind::CuttlefishSchema => {}
230                SourceKind::Elixir => {
231                    if let Some(path) = file.primary_path() {
232                        unsupported_files.push(path.to_path_buf());
233                    }
234                }
235                SourceKind::Other => {}
236            }
237        }
238        referenced.sort();
239        // Added sorts before Context within a kind, so kind-level dedup keeps the strongest origin.
240        referenced.dedup_by(|a, b| a.kind == b.kind);
241        defined.sort();
242        defined.dedup();
243        unsupported_files.sort();
244        unsupported_files.dedup();
245        Patch {
246            files: self.files,
247            referenced,
248            defined,
249            dynamic_calls,
250            unsupported_files,
251            call_args,
252            _state: PhantomData,
253        }
254    }
255}
256
257/// One pin's filesystem state at evaluation time. Holds the content of
258/// every path the patch touches, or `None` for paths absent at that pin.
259/// Built by the caller (the CLI uses `gix`); the analyzer itself never
260/// touches git.
261#[derive(Debug, Clone, Default, PartialEq, Eq)]
262pub struct EvaluationFiles {
263    files: BTreeMap<PathBuf, Option<Vec<u8>>>,
264}
265
266impl EvaluationFiles {
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    #[must_use]
272    pub fn with(mut self, path: impl Into<PathBuf>, contents: Option<Vec<u8>>) -> Self {
273        self.files.insert(path.into(), contents);
274        self
275    }
276
277    pub fn get(&self, path: &Path) -> Option<&Option<Vec<u8>>> {
278        self.files.get(path)
279    }
280
281    pub fn is_empty(&self) -> bool {
282        self.files.is_empty()
283    }
284
285    pub fn len(&self) -> usize {
286        self.files.len()
287    }
288}
289
290pub mod evaluation_state {
291    use super::{EvaluationFiles, PinScope};
292
293    /// State markers carry the data they describe: `Scoped` carries the
294    /// scope, `Sourced` carries scope plus files. This is how we avoid
295    /// `Option<PinScope>` + runtime `.expect` panics: the type system
296    /// proves field presence at the call site.
297    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
298    pub struct Pinned;
299
300    #[derive(Debug, Clone, PartialEq, Eq)]
301    pub struct Scoped {
302        pub(super) scope: PinScope,
303    }
304
305    #[derive(Debug, Clone, PartialEq, Eq)]
306    pub struct Sourced {
307        pub(super) scope: PinScope,
308        pub(super) files: EvaluationFiles,
309    }
310}
311
312pub use evaluation_state::{Pinned, Scoped, Sourced};
313
314/// Inputs for one pin's evaluation, lifted to a type-state pipeline so
315/// half-built contexts cannot reach the evaluator. The progression is
316/// `Pinned` (identity) → `Scoped` (added module/record scope) → `Sourced`
317/// (added file bytes for context-drift checks). Only `Scoped` and
318/// `Sourced` contexts can be evaluated; the `EvaluationInput` trait lets
319/// the evaluator accept either. Per-state data is stored in the marker
320/// (`extras`), so there are no `Option` fields and no runtime `expect`s.
321///
322/// `source_snapshot` is optional regardless of state: it carries the
323/// matching snapshot at the *source* pin (the version the patch was
324/// authored against), enabling spec and record-field diffs that flag
325/// signature narrowing between source and target.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct EvaluationContext<S = Scoped> {
328    pin: Pin,
329    // Arc: batch evaluation reuses one parsed snapshot across many (commit, series) pairs without deep clones
330    snapshot: Arc<Snapshot<state::Canonical>>,
331    source_snapshot: Option<Arc<Snapshot<state::Canonical>>>,
332    family_defaults: Option<FamilyDefaults>,
333    extras: S,
334}
335
336impl EvaluationContext<Pinned> {
337    pub fn for_pin(pin: Pin, snapshot: impl Into<Arc<Snapshot<state::Canonical>>>) -> Self {
338        Self {
339            pin,
340            snapshot: snapshot.into(),
341            source_snapshot: None,
342            family_defaults: None,
343            extras: Pinned,
344        }
345    }
346
347    #[must_use]
348    pub fn with_scope(self, scope: PinScope) -> EvaluationContext<Scoped> {
349        EvaluationContext {
350            pin: self.pin,
351            snapshot: self.snapshot,
352            source_snapshot: self.source_snapshot,
353            family_defaults: self.family_defaults,
354            extras: Scoped { scope },
355        }
356    }
357}
358
359impl EvaluationContext<Scoped> {
360    /// Shortcut for `EvaluationContext::for_pin(pin, snap).with_scope(scope)`.
361    pub fn new(
362        pin: Pin,
363        snapshot: impl Into<Arc<Snapshot<state::Canonical>>>,
364        scope: PinScope,
365    ) -> Self {
366        EvaluationContext::for_pin(pin, snapshot).with_scope(scope)
367    }
368
369    #[must_use]
370    pub fn with_files(self, files: EvaluationFiles) -> EvaluationContext<Sourced> {
371        EvaluationContext {
372            pin: self.pin,
373            snapshot: self.snapshot,
374            source_snapshot: self.source_snapshot,
375            family_defaults: self.family_defaults,
376            extras: Sourced {
377                scope: self.extras.scope,
378                files,
379            },
380        }
381    }
382}
383
384impl<S> EvaluationContext<S> {
385    #[must_use]
386    pub fn with_source_snapshot(
387        mut self,
388        source: impl Into<Arc<Snapshot<state::Canonical>>>,
389    ) -> Self {
390        self.source_snapshot = Some(source.into());
391        self
392    }
393
394    /// Attach the pin's project-family declarations. Contexts without
395    /// this skip the versioned-machine and wire-constant checks.
396    #[must_use]
397    pub fn with_family_defaults(mut self, defaults: FamilyDefaults) -> Self {
398        self.family_defaults = Some(defaults);
399        self
400    }
401}
402
403/// Anything the evaluator can read: a context that has at least a scope.
404/// Implemented for `EvaluationContext<Scoped>` and `EvaluationContext<Sourced>`.
405pub trait EvaluationInput {
406    fn pin(&self) -> &Pin;
407    fn snapshot(&self) -> &Snapshot<state::Canonical>;
408    fn scope(&self) -> &PinScope;
409    fn files(&self) -> Option<&EvaluationFiles>;
410    fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>>;
411    fn family_defaults(&self) -> Option<&FamilyDefaults>;
412}
413
414impl EvaluationInput for EvaluationContext<Scoped> {
415    fn pin(&self) -> &Pin {
416        &self.pin
417    }
418    fn snapshot(&self) -> &Snapshot<state::Canonical> {
419        &self.snapshot
420    }
421    fn scope(&self) -> &PinScope {
422        &self.extras.scope
423    }
424    fn files(&self) -> Option<&EvaluationFiles> {
425        None
426    }
427    fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
428        self.source_snapshot.as_deref()
429    }
430    fn family_defaults(&self) -> Option<&FamilyDefaults> {
431        self.family_defaults.as_ref()
432    }
433}
434
435impl EvaluationInput for EvaluationContext<Sourced> {
436    fn pin(&self) -> &Pin {
437        &self.pin
438    }
439    fn snapshot(&self) -> &Snapshot<state::Canonical> {
440        &self.snapshot
441    }
442    fn scope(&self) -> &PinScope {
443        &self.extras.scope
444    }
445    fn files(&self) -> Option<&EvaluationFiles> {
446        Some(&self.extras.files)
447    }
448    fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
449        self.source_snapshot.as_deref()
450    }
451    fn family_defaults(&self) -> Option<&FamilyDefaults> {
452        self.family_defaults.as_ref()
453    }
454}
455
456impl Patch<Analyzed> {
457    pub fn referenced(&self) -> &[SymbolRef] {
458        &self.referenced
459    }
460
461    pub fn defined(&self) -> &[SymbolRef] {
462        &self.defined
463    }
464
465    /// Fold in references a caller extracted from a source the core
466    /// analyzer does not parse itself, such as the Erlang bodies inside a
467    /// cuttlefish `.schema` file. The merged set is re-sorted and deduped
468    /// on kind, keeping the strongest origin, exactly as `analyze` leaves
469    /// its own references.
470    #[must_use]
471    pub fn with_extra_references(mut self, refs: impl IntoIterator<Item = SymbolRef>) -> Self {
472        self.referenced.extend(refs);
473        self.referenced.sort();
474        self.referenced.dedup_by(|a, b| a.kind == b.kind);
475        self
476    }
477
478    /// True when every changed `.erl` hunk is a Variant A unwrap of
479    /// test-only `-ifdef(TEST)` guards (see
480    /// `InapplicableReason::OnlyTestVisibilityChanged`). Callers stamp
481    /// the result onto each `PinVerdict.touched.only_test_visibility`
482    /// so the inapplicable promoter can flip the verdict.
483    pub fn is_only_test_visibility_change(&self) -> bool {
484        is_only_test_visibility_change(&self.files)
485    }
486
487    fn analyzed_inputs(&self) -> AnalyzedInputs<'_> {
488        AnalyzedInputs {
489            files: &self.files,
490            referenced: &self.referenced,
491            defined: &self.defined,
492            unsupported_files: &self.unsupported_files,
493            call_args: &self.call_args,
494        }
495    }
496
497    /// Scope-aware evaluation. Out-of-scope call sites and record
498    /// references are skipped per pin and tallied series-wide into a
499    /// `Diagnostics` envelope kept strictly outside `Verdict.reasons`.
500    /// Generic over `EvaluationInput` so callers pass `Scoped` (no file
501    /// checks) or `Sourced` (with file-context checks) contexts.
502    pub fn evaluate_series<C: EvaluationInput>(self, contexts: &[C]) -> SeriesEvaluation {
503        let series_modules: BTreeSet<ModuleName> = contexts
504            .iter()
505            .flat_map(|c| c.scope().modules().iter().cloned())
506            .collect();
507        let series_records: BTreeSet<RecordName> = contexts
508            .iter()
509            .flat_map(|c| c.scope().records().iter().cloned())
510            .collect();
511        let mut untracked_calls = UntrackedTally::default();
512        let mut untracked_records: BTreeMap<RecordName, usize> = BTreeMap::new();
513        let defined_index: HashSet<&SymbolKind> = self.defined.iter().map(|d| &d.kind).collect();
514        for r in &self.referenced {
515            if defined_index.contains(&r.kind) {
516                continue;
517            }
518            match &r.kind {
519                SymbolKind::Function { mfa } if !series_modules.contains(&mfa.module) => {
520                    untracked_calls.record(mfa.module.clone());
521                }
522                SymbolKind::FunctionAnyArity { module, .. } if !series_modules.contains(module) => {
523                    untracked_calls.record(module.clone());
524                }
525                SymbolKind::Record { name } if !series_records.contains(name) => {
526                    *untracked_records.entry(name.clone()).or_insert(0) += 1;
527                }
528                _ => {}
529            }
530        }
531        let mut results = Vec::with_capacity(contexts.len());
532        let mut context_refs_missing: BTreeMap<ModuleName, usize> = BTreeMap::new();
533        // The pin that considered the most hunks supplies the series-level
534        // content-presence diagnostic: in cross-branch mode that is the self pin.
535        let mut best_presence: Option<ContentPresence> = None;
536        for ctx in contexts {
537            let r = evaluate_pin(
538                self.analyzed_inputs(),
539                ctx.snapshot(),
540                PinEvalOptions {
541                    source_snapshot: ctx.source_snapshot(),
542                    pin_files: ctx.files(),
543                    scope: Some(ctx.scope()),
544                    family_defaults: ctx.family_defaults(),
545                },
546            );
547            if let Some(tally) = r.postimage {
548                let denser = best_presence
549                    .as_ref()
550                    .is_none_or(|b| tally.considered > b.hunks_considered);
551                if denser {
552                    best_presence = Some(content_presence_from_tally(
553                        ctx.pin().project.clone(),
554                        tally,
555                    ));
556                }
557            }
558            // Pin scopes are disjoint per project, so summing per-pin tallies does not double count a module.
559            for (module, count) in r.context_missing {
560                *context_refs_missing.entry(module).or_insert(0) += count;
561            }
562            results.push(
563                PinVerdict::new(ctx.pin().clone(), r.verdict)
564                    .with_tracked_ref_details(r.tracked_refs)
565                    .with_source_delta_details(r.source_deltas),
566            );
567        }
568        let mut unanalyzed = Unanalyzed::default();
569        for d in &self.dynamic_calls {
570            match d {
571                DynamicCall::Apply => unanalyzed.apply += 1,
572                DynamicCall::VariableDispatch => unanalyzed.variable_dispatch += 1,
573            }
574        }
575        let already_present = best_presence.map(|content| AlreadyPresent {
576            identical: None,
577            content: Some(content),
578        });
579        SeriesEvaluation {
580            verdict: SeriesVerdict::from_results(results),
581            diagnostics: Diagnostics {
582                untracked_calls: untracked_calls.into_map(),
583                untracked_records,
584                context_refs_missing,
585                unattributed_paths: BTreeMap::new(),
586                unanalyzed,
587                suggested_suites: suggest_suites(&self.files),
588                missing_test_modules: BTreeMap::new(),
589                already_present,
590                already_present_skipped: None,
591                dep_pin_divergence: Vec::new(),
592                pin_bumps: Vec::new(),
593                qualified_call_shape_checks: ShapeCheckTally::default(),
594                local_call_shape_checks: ShapeCheckTally::default(),
595                macro_value_checks: MacroValueTally::default(),
596                indirect_call_checks: IndirectCallTally::default(),
597                dormant_detectors: Vec::new(),
598            },
599            patch_facts: classify_patch_facts(&self.files),
600            touched_paths: collect_touched_paths(&self.files),
601            pr_commits: None,
602            target: TargetAxisSlot::absent(),
603        }
604    }
605}
606
607/// Project a per-pin tally into the envelope shape, converting the
608/// per-file keys to `RelativePath` with the same drop-on-failure
609/// policy as `collect_touched_paths`.
610fn content_presence_from_tally(pin: ProjectName, tally: PostimageTally) -> ContentPresence {
611    let mut per_file = BTreeMap::new();
612    for (path, counts) in tally.per_file {
613        let Some(s) = path.to_str() else { continue };
614        let Ok(rel) = RelativePath::new(s.to_owned()) else {
615            continue;
616        };
617        per_file.insert(rel, counts);
618    }
619    ContentPresence {
620        pin,
621        hunks_already_applied: tally.applied,
622        hunks_considered: tally.considered,
623        hunks_ambiguous: tally.ambiguous,
624        hunks_low_confidence: tally.low_confidence,
625        per_file,
626    }
627}
628
629fn collect_touched_paths(files: &[PatchedFile]) -> Vec<RelativePath> {
630    let mut seen: BTreeSet<RelativePath> = BTreeSet::new();
631    let mut out = Vec::with_capacity(files.len());
632    for file in files {
633        for p in [file.new_path.as_ref(), file.old_path.as_ref()]
634            .into_iter()
635            .flatten()
636        {
637            let Some(s) = p.to_str() else { continue };
638            let Ok(rel) = RelativePath::new(s.to_owned()) else {
639                continue;
640            };
641            if seen.insert(rel.clone()) {
642                out.push(rel);
643            }
644        }
645    }
646    out
647}