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