Skip to main content

backhopper_core/compat/
qualified_call_resolve.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//! Resolves qualified `m:f/a` calls a patch adds against the called
6//! module's exports on the target tree: the snapshot-free equivalent
7//! of `local_call_resolve` for the qualified case. A call is flagged
8//! only when its module is first-party (present on the target tree and
9//! covered by no pin snapshot), the export set is fully readable, and
10//! neither the target nor the patch exports `f/a`. A call that does
11//! resolve gets its `-spec` return shape compared between the source
12//! and target checkouts, with the same comparator and guards the
13//! tracked-dependency axis uses; every withheld comparison is counted
14//! in a `ShapeCheckTally` so a shape-blind round is distinguishable
15//! from a compared-clean one. Everything else is withheld and left to
16//! the build.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::fmt;
20
21use crate::compat::added_lines::{AddedLinesSubject, file_line};
22use crate::compat::call_sites::extract_qualified_calls_with_context;
23use crate::compat::indirect_calls::{
24    IndirectExtraction, extract_indirect_calls_elixir, extract_indirect_calls_with_context,
25};
26use crate::compat::source_attributes::{
27    ExportSurface, Presence, SpecTable, Surface, extract_exports, extract_function_signatures,
28    extract_specs,
29};
30use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath};
31use crate::model::spec_ast::SpecType;
32use crate::model::spec_parser::parse_signature_return;
33use crate::model::symbol::LineClass;
34use crate::model::verdict::{IndirectCallForm, IndirectCallTally, Reason, ShapeCheckTally};
35
36/// `(function, arity)` pairs per module.
37pub type PerModuleFunctionSet = BTreeMap<ModuleName, BTreeSet<(FunctionName, Arity)>>;
38
39/// An `AddedLinesSubject` paired with its per-blob-line attribute-region
40/// classification, computed against the full hunk (`Context` lines
41/// included) rather than the added-only blob: a continuation line whose
42/// multi-line attribute opener is unchanged, and so absent from
43/// `added_text`, still classifies against the region its unseen opener
44/// started. Only the qualified-call and Erlang indirect-call extractors
45/// need this; every other axis over `AddedLinesSubject` carries no
46/// attribute-context concept.
47#[derive(Debug, Clone, Copy)]
48pub struct ContextAwareSubject<'a> {
49    pub subject: AddedLinesSubject<'a>,
50    pub line_context: &'a [LineClass],
51}
52
53/// What the patch's added lines themselves provide per module. A
54/// qualified call to an added or newly exported function is not
55/// flagged, since the patch introduces or covers it (often in a
56/// different file than the call). Where the module's own export
57/// surface is unreadable (`-compile(export_all)` and the like), a call
58/// to any of its functions withholds rather than flags: the patch may
59/// cover the callee in a way the scanner cannot see. A resolved call
60/// whose `-spec` the patch rewrites is not shape-compared: post-apply,
61/// both trees carry the patch's version, so there is no pre-existing
62/// drift to find.
63#[derive(Debug, Default)]
64pub struct PatchProvided {
65    pub functions: BTreeMap<ModuleName, ExportSurface>,
66    pub specs: PerModuleFunctionSet,
67}
68
69/// Whether a patch-provided surface covers a key: present because the
70/// patch defines or exports it, or unknowable because the module's own
71/// export surface cannot be read in full.
72fn patch_covers(
73    functions: &BTreeMap<ModuleName, ExportSurface>,
74    module: &ModuleName,
75    key: &(FunctionName, Arity),
76) -> bool {
77    functions
78        .get(module)
79        .is_some_and(|surface| !matches!(surface.lookup(key), Presence::Absent))
80}
81
82/// Reads one module file's text by tree-relative path: a git blob for
83/// the target side, the on-disk checkout for the source side.
84pub type TreeReader<'a> = &'a dyn Fn(&RelativePath) -> Option<String>;
85
86/// The immutable inputs a single `m:f/arity` resolution needs. Shared
87/// by the qualified-call axis and the import-resolved local-call path
88/// so both resolve a reference against the target tree the same way.
89pub struct ReferenceContext<'a> {
90    pub covered_modules: &'a BTreeSet<ModuleName>,
91    pub patch_added: &'a PatchProvided,
92    pub resolve_module_path: &'a dyn Fn(&ModuleName) -> Option<RelativePath>,
93    pub read_target: TreeReader<'a>,
94    pub read_source: Option<TreeReader<'a>>,
95}
96
97impl fmt::Debug for ReferenceContext<'_> {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.debug_struct("ReferenceContext")
100            .field("covered_modules", self.covered_modules)
101            .field("patch_added", self.patch_added)
102            .finish_non_exhaustive()
103    }
104}
105
106/// The caches a run of reference resolutions accumulates. The module
107/// surface and spec caches persist across subjects; the dedup sets are
108/// per-subject and cleared by `begin_subject`.
109#[derive(Debug, Default)]
110pub struct ReferenceCaches {
111    surfaces: BTreeMap<ModuleName, Option<TargetModuleSurface>>,
112    specs: BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
113    flagged: BTreeSet<(ModuleName, FunctionName, Arity)>,
114    shape_seen: BTreeSet<(ModuleName, FunctionName, Arity)>,
115}
116
117impl ReferenceCaches {
118    /// Clear the per-subject dedup sets while keeping the module surface
119    /// and spec caches, so one `m:f/a` flags once per subject but a
120    /// module is read once per run.
121    pub fn begin_subject(&mut self) {
122        self.flagged.clear();
123        self.shape_seen.clear();
124    }
125}
126
127/// Resolve one `m:f/arity` reference against the target tree: classify
128/// the module, read its surface once, and push a reason when the callee
129/// is undefined or its `-spec` return shape drifts. The single
130/// definition of undefined-on-target for both the qualified-call axis
131/// and a call resolved through a patch-added `-import`.
132#[allow(clippy::too_many_arguments)]
133pub fn resolve_qualified_reference(
134    subject: &AddedLinesSubject<'_>,
135    module: &ModuleName,
136    key: &(FunctionName, Arity),
137    call_line: u32,
138    ctx: &ReferenceContext<'_>,
139    caches: &mut ReferenceCaches,
140    reasons: &mut Vec<Reason>,
141    shape_checks: &mut ShapeCheckTally,
142) {
143    let surface = caches.surfaces.entry(module.clone()).or_insert_with(|| {
144        target_module_surface(
145            module,
146            ctx.covered_modules,
147            ctx.resolve_module_path,
148            ctx.read_target,
149        )
150    });
151    let Some(surface) = surface.as_ref() else {
152        return;
153    };
154    let presence = surface.exports.lookup(key);
155    let path = surface.path.clone();
156    // a callee the patch introduces or exports has no pre-existing shape to compare
157    if patch_covers(&ctx.patch_added.functions, module, key) {
158        return;
159    }
160    match presence {
161        Presence::Present => {
162            // a spec the patch rewrites has no pre-existing drift to compare
163            if ctx
164                .patch_added
165                .specs
166                .get(module)
167                .is_some_and(|s| s.contains(key))
168            {
169                return;
170            }
171            if !caches
172                .shape_seen
173                .insert((module.clone(), key.0.clone(), key.1))
174            {
175                return;
176            }
177            check_return_shape(
178                subject,
179                module,
180                &path,
181                key,
182                call_line,
183                ctx.read_target,
184                ctx.read_source,
185                &mut caches.specs,
186                reasons,
187                shape_checks,
188            );
189        }
190        // the export surface cannot rule the key in or out: withhold
191        Presence::Unknowable(_) => {}
192        Presence::Absent => {
193            if !caches
194                .flagged
195                .insert((module.clone(), key.0.clone(), key.1))
196            {
197                return;
198            }
199            reasons.push(Reason::QualifiedCallUndefinedOnTarget {
200                source_path: subject.source_path.clone(),
201                module: module.clone(),
202                function: key.0.clone(),
203                arity: key.1,
204                line: file_line(subject.line_map, call_line),
205            });
206        }
207    }
208}
209
210/// How a called module is classified for this axis. Only `FirstParty`
211/// is resolved; the other two are withheld, so a dependency call or an
212/// OTP call cannot be flagged by construction.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum ModuleProvenance {
215    /// A pin snapshot (tracked dep or self-pin) already resolves this
216    /// module's calls: defer to the snapshot axis.
217    CoveredBySnapshot,
218    /// A first-party `.erl` module present on the target tree, covered
219    /// by no snapshot: this axis resolves it live against its exports.
220    FirstParty { path: RelativePath },
221    /// Covered by no snapshot and absent from the tree (OTP, stdlib, or
222    /// a genuinely-missing module): withhold, the build is the backstop.
223    Unknown,
224}
225
226/// Classify a called module. Snapshot coverage wins over an in-tree
227/// file, so a tracked dep that also has a vendored copy in the tree
228/// still defers to its snapshot.
229pub fn classify_module(
230    module: &ModuleName,
231    covered_modules: &BTreeSet<ModuleName>,
232    resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
233) -> ModuleProvenance {
234    if covered_modules.contains(module) {
235        return ModuleProvenance::CoveredBySnapshot;
236    }
237    match resolve_module_path(module) {
238        Some(path) => ModuleProvenance::FirstParty { path },
239        None => ModuleProvenance::Unknown,
240    }
241}
242
243/// What the patch adds per module, gathered across every touched
244/// `.erl` file so a cross-file add (call in one file, callee and its
245/// `-export` in the module's own file) does not false-positive.
246pub fn patch_provided(per_file: &[(ModuleName, &str)]) -> PatchProvided {
247    let mut out = PatchProvided::default();
248    for (module, added_text) in per_file {
249        let mut defined = BTreeSet::new();
250        for sig in extract_function_signatures(added_text) {
251            if !sig.is_definition {
252                continue;
253            }
254            if let Ok(arity) = Arity::try_from(sig.arity) {
255                defined.insert((sig.name.clone(), arity));
256            }
257        }
258        let surface = out.functions.entry(module.clone()).or_default();
259        surface.merge(Surface::new(defined, None));
260        surface.merge(extract_exports(added_text));
261        let specs = extract_specs(added_text);
262        if !specs.is_empty() {
263            out.specs
264                .entry(module.clone())
265                .or_default()
266                .extend(specs.into_keys());
267        }
268    }
269    out
270}
271
272/// What the qualified-call gate produced: the export-resolution
273/// reasons plus the return-shape findings, and the tally of what the
274/// shape check did with each resolved call.
275#[derive(Debug, Default)]
276pub struct QualifiedCallAnalysis {
277    pub reasons: Vec<Reason>,
278    pub shape_checks: ShapeCheckTally,
279    pub indirect_checks: IndirectCallTally,
280}
281
282/// The target module's readable surface for this axis: what it exports
283/// and what it defines. The defined set exists for the indirect gate,
284/// where a meck expectation on an unexported function is legitimate.
285#[derive(Debug)]
286struct TargetModuleSurface {
287    exports: ExportSurface,
288    defined: BTreeSet<(FunctionName, Arity)>,
289    path: RelativePath,
290}
291
292/// Flag each added qualified call whose module is first-party on the
293/// target and does not export the called `f/a`, and compare the
294/// `-spec` return shape of each call that does resolve.
295/// `resolve_module_path` maps a module to its `.erl` path on the
296/// target tree (the first-party gate: `None` means absent, so
297/// withhold); `read_target` reads that path at the resolved target
298/// commit; `read_source` reads it from the source checkout (`None`
299/// when no checkout is available, which withholds every shape check).
300/// `covered_modules` is the union of every pin snapshot's modules: a
301/// call into one of them is resolved by the snapshot axis, not this one. One
302/// reason per `(file, module, function, arity)`.
303pub fn analyse_qualified_calls(
304    subjects: &[ContextAwareSubject<'_>],
305    covered_modules: &BTreeSet<ModuleName>,
306    patch_added: &PatchProvided,
307    resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
308    read_target: TreeReader<'_>,
309    read_source: Option<TreeReader<'_>>,
310) -> QualifiedCallAnalysis {
311    let mut analysis = QualifiedCallAnalysis::default();
312    let ctx = ReferenceContext {
313        covered_modules,
314        patch_added,
315        resolve_module_path,
316        read_target,
317        read_source,
318    };
319    let mut caches = ReferenceCaches::default();
320    for subject in subjects {
321        let calls = extract_qualified_calls_with_context(
322            subject.subject.added_text,
323            subject.subject.line_map,
324            subject.line_context,
325        );
326        if calls.is_empty() {
327            continue;
328        }
329        caches.begin_subject();
330        for call in calls {
331            let key = (call.mfa.function.clone(), call.mfa.arity);
332            resolve_qualified_reference(
333                &subject.subject,
334                &call.mfa.module,
335                &key,
336                call.line,
337                &ctx,
338                &mut caches,
339                &mut analysis.reasons,
340                &mut analysis.shape_checks,
341            );
342        }
343    }
344    for subject in subjects {
345        resolve_indirect_calls(subject, &ctx, &mut caches, &mut analysis);
346    }
347    analysis
348}
349
350/// Classify a module and read its export and definition surface once.
351/// `None` caches the withheld cases at the module level: covered or
352/// absent. An unreadable export surface is cached too, and withheld
353/// per key by `lookup` rather than for the whole module: a listed
354/// export under `-compile(export_all)` still resolves.
355fn target_module_surface(
356    module: &ModuleName,
357    covered_modules: &BTreeSet<ModuleName>,
358    resolve_module_path: &dyn Fn(&ModuleName) -> Option<RelativePath>,
359    read_target: TreeReader<'_>,
360) -> Option<TargetModuleSurface> {
361    match classify_module(module, covered_modules, resolve_module_path) {
362        ModuleProvenance::FirstParty { path } => {
363            let text = read_target(&path)?;
364            let exports = extract_exports(&text);
365            let defined = extract_function_signatures(&text)
366                .into_iter()
367                .filter(|sig| sig.is_definition)
368                .filter_map(|sig| Some((sig.name, Arity::try_from(sig.arity).ok()?)))
369                .collect();
370            Some(TargetModuleSurface {
371                exports,
372                defined,
373                path,
374            })
375        }
376        ModuleProvenance::CoveredBySnapshot | ModuleProvenance::Unknown => None,
377    }
378}
379
380/// The indirect-reference axis for Elixir sources. `:module.function`
381/// calls of a recognized form are extracted from the Elixir added lines
382/// and resolved against the same Erlang target tree the qualified-call
383/// axis reads. Only the extractor differs from the Erlang path; the
384/// resolution, gate, and reason are shared.
385pub fn analyse_indirect_elixir_calls(
386    subjects: &[AddedLinesSubject<'_>],
387    ctx: &ReferenceContext<'_>,
388    caches: &mut ReferenceCaches,
389) -> IndirectCallAnalysis {
390    let mut out = IndirectCallAnalysis::default();
391    for subject in subjects {
392        let extraction = extract_indirect_calls_elixir(subject.added_text, subject.line_map);
393        resolve_extraction(
394            &extraction,
395            subject,
396            ctx,
397            caches,
398            &mut out.reasons,
399            &mut out.tally,
400        );
401    }
402    out
403}
404
405/// The reasons and tally the indirect-reference axis produces on its
406/// own, so the Elixir path can run beside the qualified-call axis.
407#[derive(Debug, Default)]
408pub struct IndirectCallAnalysis {
409    pub reasons: Vec<Reason>,
410    pub tally: IndirectCallTally,
411}
412
413/// Resolve one subject's Erlang indirect MFA references against the
414/// target modules' surfaces, folding the reasons and tally into the
415/// qualified-call analysis.
416fn resolve_indirect_calls(
417    subject: &ContextAwareSubject<'_>,
418    ctx: &ReferenceContext<'_>,
419    caches: &mut ReferenceCaches,
420    analysis: &mut QualifiedCallAnalysis,
421) {
422    let extraction = extract_indirect_calls_with_context(
423        subject.subject.added_text,
424        subject.subject.line_map,
425        subject.line_context,
426    );
427    resolve_extraction(
428        &extraction,
429        &subject.subject,
430        ctx,
431        caches,
432        &mut analysis.reasons,
433        &mut analysis.indirect_checks,
434    );
435}
436
437/// Resolve extracted indirect sites against the target modules' export
438/// and definition sets, language-independent below the extractor. A
439/// site is flagged only when the target module neither exports nor
440/// defines it and the patch does not add it. Shares `ReferenceContext`
441/// and the surface cache with the qualified-call axis, so a module read
442/// once serves both.
443fn resolve_extraction(
444    extraction: &IndirectExtraction,
445    subject: &AddedLinesSubject<'_>,
446    ctx: &ReferenceContext<'_>,
447    caches: &mut ReferenceCaches,
448    reasons: &mut Vec<Reason>,
449    tally: &mut IndirectCallTally,
450) {
451    tally.withheld_dynamic += extraction.withheld_dynamic.len();
452    let mut seen: BTreeSet<(ModuleName, FunctionName, Arity, IndirectCallForm)> = BTreeSet::new();
453    for site in &extraction.sites {
454        let (module, function, arity, via) = (
455            &site.mfa.module,
456            &site.mfa.function,
457            site.mfa.arity,
458            site.via,
459        );
460        let entry = caches.surfaces.entry(module.clone()).or_insert_with(|| {
461            target_module_surface(
462                module,
463                ctx.covered_modules,
464                ctx.resolve_module_path,
465                ctx.read_target,
466            )
467        });
468        let Some(surface) = entry.as_ref() else {
469            continue;
470        };
471        if !seen.insert((module.clone(), function.clone(), arity, via)) {
472            continue;
473        }
474        tally.checked += 1;
475        let key = (function.clone(), arity);
476        if matches!(surface.exports.lookup(&key), Presence::Present)
477            || surface.defined.contains(&key)
478            || patch_covers(&ctx.patch_added.functions, module, &key)
479        {
480            continue;
481        }
482        reasons.push(Reason::IndirectCallUndefinedOnTarget {
483            source_path: subject.source_path.clone(),
484            module: module.clone(),
485            function: function.clone(),
486            arity,
487            via,
488            line: file_line(subject.line_map, site.line),
489        });
490    }
491}
492
493/// Outcome of comparing one function's `-spec` return shape between
494/// two spec tables. The guards are `check_return_shapes`' from the
495/// snapshot axis: present on both sides, both parse to a known
496/// `SpecType`, shapes disagree.
497#[derive(Debug, PartialEq, Eq)]
498pub enum ShapeComparison {
499    /// Both sides have a spec and the shapes agree.
500    Same,
501    /// Both sides have a spec and the shapes disagree.
502    Drift {
503        source_signature: String,
504        target_signature: String,
505    },
506    /// One or both sides declare no `-spec` for the key.
507    NoSpec,
508    /// Both sides have a spec but at least one parses to
509    /// `SpecType::Unknown`.
510    UnknownType,
511}
512
513pub fn compare_return_shapes(
514    target_specs: &SpecTable,
515    source_specs: &SpecTable,
516    key: &(FunctionName, Arity),
517) -> ShapeComparison {
518    let (Some(target_sig), Some(source_sig)) = (target_specs.get(key), source_specs.get(key))
519    else {
520        return ShapeComparison::NoSpec;
521    };
522    // Identical text means identical shape: no parse needed.
523    if target_sig == source_sig {
524        return ShapeComparison::Same;
525    }
526    let target_ast = parse_signature_return(target_sig);
527    let source_ast = parse_signature_return(source_sig);
528    if matches!(target_ast, SpecType::Unknown) || matches!(source_ast, SpecType::Unknown) {
529        return ShapeComparison::UnknownType;
530    }
531    if target_ast.matches(&source_ast) {
532        ShapeComparison::Same
533    } else {
534        ShapeComparison::Drift {
535            source_signature: source_sig.clone(),
536            target_signature: target_sig.clone(),
537        }
538    }
539}
540
541/// Every guard that declines to compare is counted rather than
542/// dropped.
543#[allow(clippy::too_many_arguments)]
544fn check_return_shape(
545    subject: &AddedLinesSubject<'_>,
546    module: &ModuleName,
547    path: &RelativePath,
548    key: &(FunctionName, Arity),
549    call_line: u32,
550    read_target: TreeReader<'_>,
551    read_source: Option<TreeReader<'_>>,
552    specs_by_module: &mut BTreeMap<ModuleName, (SpecTable, Option<SpecTable>)>,
553    reasons: &mut Vec<Reason>,
554    tally: &mut ShapeCheckTally,
555) {
556    let Some(read_source) = read_source else {
557        tally.withheld_no_source += 1;
558        return;
559    };
560    let (target_specs, source_specs) = specs_by_module.entry(module.clone()).or_insert_with(|| {
561        (
562            read_target(path)
563                .map(|t| extract_specs(&t))
564                .unwrap_or_default(),
565            read_source(path).map(|t| extract_specs(&t)),
566        )
567    });
568    let Some(source_specs) = source_specs.as_ref() else {
569        tally.withheld_no_source += 1;
570        return;
571    };
572    match compare_return_shapes(target_specs, source_specs, key) {
573        ShapeComparison::Same => tally.compared += 1,
574        ShapeComparison::NoSpec => tally.withheld_no_spec += 1,
575        ShapeComparison::UnknownType => tally.withheld_unknown_type += 1,
576        ShapeComparison::Drift {
577            source_signature,
578            target_signature,
579        } => {
580            tally.compared += 1;
581            reasons.push(Reason::QualifiedCallReturnShapeDrift {
582                source_path: subject.source_path.clone(),
583                module: module.clone(),
584                function: key.0.clone(),
585                arity: key.1,
586                source_signature,
587                target_signature,
588                line: file_line(subject.line_map, call_line),
589            });
590        }
591    }
592}