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