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