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, 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                // Schema files embed Erlang fun bodies in a config-key DSL; the
210                // cuttlefish parser sits outside this crate and injects their references.
211                SourceKind::CuttlefishSchema => {}
212                SourceKind::Elixir => {
213                    if let Some(path) = file.primary_path() {
214                        unsupported_files.push(path.to_path_buf());
215                    }
216                }
217                SourceKind::Other => {}
218            }
219        }
220        referenced.sort();
221        // Added sorts before Context within a kind, so kind-level dedup keeps the strongest origin.
222        referenced.dedup_by(|a, b| a.kind == b.kind);
223        defined.sort();
224        defined.dedup();
225        unsupported_files.sort();
226        unsupported_files.dedup();
227        Patch {
228            files: self.files,
229            referenced,
230            defined,
231            dynamic_calls,
232            unsupported_files,
233            call_args,
234            _state: PhantomData,
235        }
236    }
237}
238
239/// One pin's filesystem state at evaluation time. Holds the content of
240/// every path the patch touches, or `None` for paths absent at that pin.
241/// Built by the caller (the CLI uses `gix`); the analyzer itself never
242/// touches git.
243#[derive(Debug, Clone, Default, PartialEq, Eq)]
244pub struct EvaluationFiles {
245    files: BTreeMap<PathBuf, Option<Vec<u8>>>,
246}
247
248impl EvaluationFiles {
249    pub fn new() -> Self {
250        Self::default()
251    }
252
253    #[must_use]
254    pub fn with(mut self, path: impl Into<PathBuf>, contents: Option<Vec<u8>>) -> Self {
255        self.files.insert(path.into(), contents);
256        self
257    }
258
259    pub fn get(&self, path: &Path) -> Option<&Option<Vec<u8>>> {
260        self.files.get(path)
261    }
262
263    pub fn is_empty(&self) -> bool {
264        self.files.is_empty()
265    }
266
267    pub fn len(&self) -> usize {
268        self.files.len()
269    }
270}
271
272pub mod evaluation_state {
273    use super::{EvaluationFiles, PinScope};
274
275    /// State markers carry the data they describe: `Scoped` carries the
276    /// scope, `Sourced` carries scope plus files. This is how we avoid
277    /// `Option<PinScope>` + runtime `.expect` panics: the type system
278    /// proves field presence at the call site.
279    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
280    pub struct Pinned;
281
282    #[derive(Debug, Clone, PartialEq, Eq)]
283    pub struct Scoped {
284        pub(super) scope: PinScope,
285    }
286
287    #[derive(Debug, Clone, PartialEq, Eq)]
288    pub struct Sourced {
289        pub(super) scope: PinScope,
290        pub(super) files: EvaluationFiles,
291    }
292}
293
294pub use evaluation_state::{Pinned, Scoped, Sourced};
295
296/// Inputs for one pin's evaluation, lifted to a type-state pipeline so
297/// half-built contexts cannot reach the evaluator. The progression is
298/// `Pinned` (identity) → `Scoped` (added module/record scope) → `Sourced`
299/// (added file bytes for context-drift checks). Only `Scoped` and
300/// `Sourced` contexts can be evaluated; the `EvaluationInput` trait lets
301/// the evaluator accept either. Per-state data is stored in the marker
302/// (`extras`), so there are no `Option` fields and no runtime `expect`s.
303///
304/// `source_snapshot` is optional regardless of state: it carries the
305/// matching snapshot at the *source* pin (the version the patch was
306/// authored against), enabling spec and record-field diffs that flag
307/// signature narrowing between source and target.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct EvaluationContext<S = Scoped> {
310    pin: Pin,
311    // Arc: batch evaluation reuses one parsed snapshot across many (commit, series) pairs without deep clones
312    snapshot: Arc<Snapshot<state::Canonical>>,
313    source_snapshot: Option<Arc<Snapshot<state::Canonical>>>,
314    family_defaults: Option<FamilyDefaults>,
315    extras: S,
316}
317
318impl EvaluationContext<Pinned> {
319    pub fn for_pin(pin: Pin, snapshot: impl Into<Arc<Snapshot<state::Canonical>>>) -> Self {
320        Self {
321            pin,
322            snapshot: snapshot.into(),
323            source_snapshot: None,
324            family_defaults: None,
325            extras: Pinned,
326        }
327    }
328
329    #[must_use]
330    pub fn with_scope(self, scope: PinScope) -> EvaluationContext<Scoped> {
331        EvaluationContext {
332            pin: self.pin,
333            snapshot: self.snapshot,
334            source_snapshot: self.source_snapshot,
335            family_defaults: self.family_defaults,
336            extras: Scoped { scope },
337        }
338    }
339}
340
341impl EvaluationContext<Scoped> {
342    /// Shortcut for `EvaluationContext::for_pin(pin, snap).with_scope(scope)`.
343    pub fn new(
344        pin: Pin,
345        snapshot: impl Into<Arc<Snapshot<state::Canonical>>>,
346        scope: PinScope,
347    ) -> Self {
348        EvaluationContext::for_pin(pin, snapshot).with_scope(scope)
349    }
350
351    #[must_use]
352    pub fn with_files(self, files: EvaluationFiles) -> EvaluationContext<Sourced> {
353        EvaluationContext {
354            pin: self.pin,
355            snapshot: self.snapshot,
356            source_snapshot: self.source_snapshot,
357            family_defaults: self.family_defaults,
358            extras: Sourced {
359                scope: self.extras.scope,
360                files,
361            },
362        }
363    }
364}
365
366impl<S> EvaluationContext<S> {
367    #[must_use]
368    pub fn with_source_snapshot(
369        mut self,
370        source: impl Into<Arc<Snapshot<state::Canonical>>>,
371    ) -> Self {
372        self.source_snapshot = Some(source.into());
373        self
374    }
375
376    /// Attach the pin's project-family declarations. Contexts without
377    /// this skip the versioned-machine and wire-constant checks.
378    #[must_use]
379    pub fn with_family_defaults(mut self, defaults: FamilyDefaults) -> Self {
380        self.family_defaults = Some(defaults);
381        self
382    }
383}
384
385/// Anything the evaluator can read: a context that has at least a scope.
386/// Implemented for `EvaluationContext<Scoped>` and `EvaluationContext<Sourced>`.
387pub trait EvaluationInput {
388    fn pin(&self) -> &Pin;
389    fn snapshot(&self) -> &Snapshot<state::Canonical>;
390    fn scope(&self) -> &PinScope;
391    fn files(&self) -> Option<&EvaluationFiles>;
392    fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>>;
393    fn family_defaults(&self) -> Option<&FamilyDefaults>;
394}
395
396impl EvaluationInput for EvaluationContext<Scoped> {
397    fn pin(&self) -> &Pin {
398        &self.pin
399    }
400    fn snapshot(&self) -> &Snapshot<state::Canonical> {
401        &self.snapshot
402    }
403    fn scope(&self) -> &PinScope {
404        &self.extras.scope
405    }
406    fn files(&self) -> Option<&EvaluationFiles> {
407        None
408    }
409    fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
410        self.source_snapshot.as_deref()
411    }
412    fn family_defaults(&self) -> Option<&FamilyDefaults> {
413        self.family_defaults.as_ref()
414    }
415}
416
417impl EvaluationInput for EvaluationContext<Sourced> {
418    fn pin(&self) -> &Pin {
419        &self.pin
420    }
421    fn snapshot(&self) -> &Snapshot<state::Canonical> {
422        &self.snapshot
423    }
424    fn scope(&self) -> &PinScope {
425        &self.extras.scope
426    }
427    fn files(&self) -> Option<&EvaluationFiles> {
428        Some(&self.extras.files)
429    }
430    fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
431        self.source_snapshot.as_deref()
432    }
433    fn family_defaults(&self) -> Option<&FamilyDefaults> {
434        self.family_defaults.as_ref()
435    }
436}
437
438impl Patch<Analyzed> {
439    pub fn referenced(&self) -> &[SymbolRef] {
440        &self.referenced
441    }
442
443    pub fn defined(&self) -> &[SymbolRef] {
444        &self.defined
445    }
446
447    /// Fold in references a caller extracted from a source the core
448    /// analyzer does not parse itself, such as the Erlang bodies inside a
449    /// cuttlefish `.schema` file. The merged set is re-sorted and deduped
450    /// on kind, keeping the strongest origin, exactly as `analyze` leaves
451    /// its own references.
452    #[must_use]
453    pub fn with_extra_references(mut self, refs: impl IntoIterator<Item = SymbolRef>) -> Self {
454        self.referenced.extend(refs);
455        self.referenced.sort();
456        self.referenced.dedup_by(|a, b| a.kind == b.kind);
457        self
458    }
459
460    /// True when every changed `.erl` hunk is a Variant A unwrap of
461    /// test-only `-ifdef(TEST)` guards (see
462    /// `InapplicableReason::OnlyTestVisibilityChanged`). Callers stamp
463    /// the result onto each `PinVerdict.touched.only_test_visibility`
464    /// so the inapplicable promoter can flip the verdict.
465    pub fn is_only_test_visibility_change(&self) -> bool {
466        is_only_test_visibility_change(&self.files)
467    }
468
469    fn analyzed_inputs(&self) -> AnalyzedInputs<'_> {
470        AnalyzedInputs {
471            files: &self.files,
472            referenced: &self.referenced,
473            defined: &self.defined,
474            unsupported_files: &self.unsupported_files,
475            call_args: &self.call_args,
476        }
477    }
478
479    /// Scope-aware evaluation. Out-of-scope call sites and record
480    /// references are skipped per pin and tallied series-wide into a
481    /// `Diagnostics` envelope kept strictly outside `Verdict.reasons`.
482    /// Generic over `EvaluationInput` so callers pass `Scoped` (no file
483    /// checks) or `Sourced` (with file-context checks) contexts.
484    pub fn evaluate_series<C: EvaluationInput>(self, contexts: &[C]) -> SeriesEvaluation {
485        let series_modules: BTreeSet<ModuleName> = contexts
486            .iter()
487            .flat_map(|c| c.scope().modules().iter().cloned())
488            .collect();
489        let series_records: BTreeSet<RecordName> = contexts
490            .iter()
491            .flat_map(|c| c.scope().records().iter().cloned())
492            .collect();
493        let mut untracked_calls = UntrackedTally::default();
494        let mut untracked_records: BTreeMap<RecordName, usize> = BTreeMap::new();
495        let defined_index: HashSet<&SymbolKind> = self.defined.iter().map(|d| &d.kind).collect();
496        for r in &self.referenced {
497            if defined_index.contains(&r.kind) {
498                continue;
499            }
500            match &r.kind {
501                SymbolKind::Function { mfa } if !series_modules.contains(&mfa.module) => {
502                    untracked_calls.record(mfa.module.clone());
503                }
504                SymbolKind::FunctionAnyArity { module, .. } if !series_modules.contains(module) => {
505                    untracked_calls.record(module.clone());
506                }
507                SymbolKind::Record { name } if !series_records.contains(name) => {
508                    *untracked_records.entry(name.clone()).or_insert(0) += 1;
509                }
510                _ => {}
511            }
512        }
513        let mut results = Vec::with_capacity(contexts.len());
514        let mut context_refs_missing: BTreeMap<ModuleName, usize> = BTreeMap::new();
515        // The pin that considered the most hunks supplies the series-level
516        // content-presence diagnostic: in cross-branch mode that is the self pin.
517        let mut best_presence: Option<ContentPresence> = None;
518        for ctx in contexts {
519            let r = evaluate_pin(
520                self.analyzed_inputs(),
521                ctx.snapshot(),
522                PinEvalOptions {
523                    source_snapshot: ctx.source_snapshot(),
524                    pin_files: ctx.files(),
525                    scope: Some(ctx.scope()),
526                    family_defaults: ctx.family_defaults(),
527                },
528            );
529            if let Some(tally) = r.postimage {
530                let denser = best_presence
531                    .as_ref()
532                    .is_none_or(|b| tally.considered > b.hunks_considered);
533                if denser {
534                    best_presence = Some(content_presence_from_tally(
535                        ctx.pin().project.clone(),
536                        tally,
537                    ));
538                }
539            }
540            // Pin scopes are disjoint per project, so summing per-pin tallies does not double count a module.
541            for (module, count) in r.context_missing {
542                *context_refs_missing.entry(module).or_insert(0) += count;
543            }
544            results.push(
545                PinVerdict::new(ctx.pin().clone(), r.verdict)
546                    .with_tracked_ref_details(r.tracked_refs)
547                    .with_source_delta_details(r.source_deltas),
548            );
549        }
550        let mut unanalyzed = Unanalyzed::default();
551        for d in &self.dynamic_calls {
552            match d {
553                DynamicCall::Apply => unanalyzed.apply += 1,
554                DynamicCall::VariableDispatch => unanalyzed.variable_dispatch += 1,
555            }
556        }
557        let already_present = best_presence.map(|content| AlreadyPresent {
558            identical: None,
559            content: Some(content),
560        });
561        SeriesEvaluation {
562            verdict: SeriesVerdict::from_results(results),
563            diagnostics: Diagnostics {
564                untracked_calls: untracked_calls.into_map(),
565                untracked_records,
566                context_refs_missing,
567                unattributed_paths: BTreeMap::new(),
568                unanalyzed,
569                suggested_suites: suggest_suites(&self.files),
570                missing_test_modules: BTreeMap::new(),
571                already_present,
572                already_present_skipped: None,
573                dep_pin_divergence: Vec::new(),
574                pin_bumps: Vec::new(),
575                qualified_call_shape_checks: ShapeCheckTally::default(),
576                local_call_shape_checks: ShapeCheckTally::default(),
577                macro_value_checks: MacroValueTally::default(),
578                indirect_call_checks: IndirectCallTally::default(),
579            },
580            patch_facts: classify_patch_facts(&self.files),
581            touched_paths: collect_touched_paths(&self.files),
582            pr_commits: None,
583            apply: None,
584            target_findings: None,
585        }
586    }
587}
588
589/// Project a per-pin tally into the envelope shape, converting the
590/// per-file keys to `RelativePath` with the same drop-on-failure
591/// policy as `collect_touched_paths`.
592fn content_presence_from_tally(pin: ProjectName, tally: PostimageTally) -> ContentPresence {
593    let mut per_file = BTreeMap::new();
594    for (path, counts) in tally.per_file {
595        let Some(s) = path.to_str() else { continue };
596        let Ok(rel) = RelativePath::new(s.to_owned()) else {
597            continue;
598        };
599        per_file.insert(rel, counts);
600    }
601    ContentPresence {
602        pin,
603        hunks_already_applied: tally.applied,
604        hunks_considered: tally.considered,
605        hunks_ambiguous: tally.ambiguous,
606        hunks_low_confidence: tally.low_confidence,
607        per_file,
608    }
609}
610
611fn collect_touched_paths(files: &[PatchedFile]) -> Vec<RelativePath> {
612    let mut seen: BTreeSet<RelativePath> = BTreeSet::new();
613    let mut out = Vec::with_capacity(files.len());
614    for file in files {
615        for p in [file.new_path.as_ref(), file.old_path.as_ref()]
616            .into_iter()
617            .flatten()
618        {
619            let Some(s) = p.to_str() else { continue };
620            let Ok(rel) = RelativePath::new(s.to_owned()) else {
621                continue;
622            };
623            if seen.insert(rel.clone()) {
624                out.push(rel);
625            }
626        }
627    }
628    out
629}