Skip to main content

sinter_resolve/
resolver.rs

1//! Evidence-based reference resolution. Tiers, strongest local knowledge
2//! first: receiver binding, typed-local binding, shadow suppression,
3//! same-file/same-module scope, then import evidence (aliases, globs,
4//! re-export chains, relative paths). Exactly one candidate or nothing —
5//! ambiguity is unresolved, never a guess.
6
7use std::collections::HashMap;
8
9use sinter_core::{
10    Confidence, Edge, Embed, Evidence, FieldBinding, LocalBinding, Node, NodeId, Reference,
11    Relation, SymbolKind, TraitImpl,
12};
13use sinter_extract::{LanguageSpec, ModuleRoot, spec_for_path};
14
15pub struct Binding {
16    pub edge: Edge,
17    /// Index into the references slice passed to [`resolve`].
18    pub reference: usize,
19}
20
21#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
22pub struct ResolutionStats {
23    pub scope: usize,
24    pub import: usize,
25    pub scip: usize,
26    /// Corpus-anchored misses subsequently resolved by compiler evidence.
27    /// This is a subset of `scip`/`scip_external`, retained so the anchored
28    /// miss denominator does not absorb compiler hits the heuristic had
29    /// classified as external.
30    pub compiler_rescued_internal: usize,
31    /// Evidence pointed into the corpus but binding failed (ambiguity,
32    /// member missing on a known module/type). This is an anchored miss,
33    /// not a complete accuracy measure: the anchoring heuristic can still
34    /// classify a compiler-resolvable corpus reference as external.
35    pub unresolved_internal: usize,
36    /// No corpus-anchored evidence: external imports, builtins, and
37    /// value-receiver calls without type evidence. Dependency-index (SCIP)
38    /// territory, not a resolver defect.
39    pub unresolved_external: usize,
40    /// References bound by both internal evidence and SCIP, split by
41    /// whether the two agreed on the target — the measured trust level
42    /// of non-scip edges.
43    pub scip_agree: usize,
44    pub scip_disagree: usize,
45    /// Refs bound to synthesized dependency-surface nodes (D29). Counted
46    /// apart from `scip` and excluded from the cross-check and recall
47    /// denominators: internal evidence can never find a symbol with no
48    /// in-corpus definition, so mixing these in would fake a regression.
49    pub scip_external: usize,
50    /// Edges from SCIP occurrences no extracted reference anchors (macro
51    /// token trees). Not references, so outside every rate denominator.
52    pub scip_unanchored: usize,
53}
54
55impl ResolutionStats {
56    pub fn resolved(&self) -> usize {
57        self.scope + self.import + self.scip + self.scip_external
58    }
59
60    pub fn unresolved(&self) -> usize {
61        self.unresolved_internal + self.unresolved_external
62    }
63
64    pub fn unresolved_rate(&self) -> f64 {
65        let total = self.resolved() + self.unresolved();
66        if total == 0 {
67            0.0
68        } else {
69            self.unresolved() as f64 / total as f64
70        }
71    }
72
73    /// Anchored unresolved references over references the heuristic itself
74    /// classified as corpus-anchored. This is useful without a compiler
75    /// index, but it is not recall: compiler evidence can prove that some
76    /// references classified as external were actually internal.
77    ///
78    /// `None` means no references were resolved in this pass. Reporting
79    /// that state as 0% would make a no-op build look perfectly accurate.
80    pub fn anchored_unresolved_rate(&self) -> Option<f64> {
81        let total =
82            self.scope + self.import + self.compiler_rescued_internal + self.unresolved_internal;
83        if total == 0 {
84            None
85        } else {
86            Some(self.unresolved_internal as f64 / total as f64)
87        }
88    }
89}
90
91/// Per-reference resolution verdict.
92enum Res {
93    Bound(Binding),
94    Internal,
95    External,
96}
97
98/// `{file}#{qualified}@{start}` -> qualified; plain file ids map to themselves.
99pub fn qualified_of(id: &str) -> &str {
100    match id.split_once('#') {
101        Some((_, rest)) => rest.rsplit_once('@').map_or(rest, |(q, _)| q),
102        None => id,
103    }
104}
105
106/// Kinds a "call" landing on means conversion/use, and namespace_pick
107/// prefers for Uses. Class is deliberately absent: instantiation really is
108/// a call (D14).
109fn is_type_kind(kind: SymbolKind) -> bool {
110    matches!(
111        kind,
112        SymbolKind::Struct
113            | SymbolKind::Enum
114            | SymbolKind::Interface
115            | SymbolKind::Trait
116            | SymbolKind::TypeAlias
117    )
118}
119
120/// Kinds that can own members for typed-local/receiver lookup — Class
121/// included here (a C++ local typed as a class binds its methods;
122/// fixture: cpp-header-impl).
123fn is_member_scope(kind: SymbolKind) -> bool {
124    is_type_kind(kind) || kind == SymbolKind::Class
125}
126
127fn is_callable(kind: SymbolKind) -> bool {
128    matches!(
129        kind,
130        SymbolKind::Function | SymbolKind::Method | SymbolKind::Macro | SymbolKind::Class
131    )
132}
133
134struct ModuleFiles<'a> {
135    key: Vec<String>,
136    files: Vec<&'a str>,
137}
138
139struct LocalRange<'a> {
140    start: u64,
141    scope_end: u64,
142    type_name: Option<&'a str>,
143}
144
145struct Import {
146    segments: Vec<String>,
147    /// Locally bound name: alias, or the last path segment.
148    binding: String,
149    /// Dot/star import: binds every top-level name of the module.
150    glob: bool,
151}
152
153struct FileDef<'a> {
154    node: &'a Node,
155    /// Qualified prefix ("Server" for Server::run; "" for top level).
156    prefix: &'a str,
157    /// Every ancestor on the prefix is function-like, so the name is
158    /// lexically visible bare inside them (nested fns yes, methods no).
159    functionish: bool,
160}
161
162/// Prebuilt lookup structures over one corpus snapshot. Built once per
163/// resolution pass and shared by [`resolve`] and [`dynamic_edges`] — the
164/// build walks every node and is the most expensive part of a pass.
165pub struct Index<'a> {
166    /// (file, plain name) -> defs with visibility info.
167    by_file_name: HashMap<(&'a str, &'a str), Vec<FileDef<'a>>>,
168    /// (file, qualified) -> def, receiver/type lookups.
169    by_file_qualified: HashMap<(&'a str, &'a str), &'a Node>,
170    /// exact file path -> file node (includes naming a literal repo file).
171    file_nodes: HashMap<&'a str, &'a Node>,
172    /// file -> its non-file defs, for fragment-slug lookup (file_refs).
173    defs_by_file: HashMap<&'a str, Vec<&'a Node>>,
174    /// name -> (absolute module segments, def).
175    by_name: HashMap<&'a str, Vec<(Vec<String>, &'a Node)>>,
176    /// last module segment -> (module segments, file node).
177    by_module_tail: HashMap<String, Vec<(Vec<String>, &'a Node)>>,
178    /// last module segment -> (module segments, files in it) — re-export
179    /// chain walking must never scan every module.
180    files_of_module: HashMap<String, Vec<ModuleFiles<'a>>>,
181    /// module segments -> top-level def name -> defs.
182    module_defs: HashMap<Vec<String>, HashMap<&'a str, Vec<&'a Node>>>,
183    /// file -> absolutized imports.
184    imports: HashMap<&'a str, Vec<Import>>,
185    /// (file, name) -> local bindings.
186    locals: HashMap<(&'a str, &'a str), Vec<LocalRange<'a>>>,
187    /// declaring type node id -> fields with written types.
188    fields: HashMap<&'a str, Vec<&'a FieldBinding>>,
189    /// owner node id -> embedded type names.
190    embeds: HashMap<&'a str, Vec<&'a str>>,
191    /// Discovered package roots (manifest-declared name <-> directory).
192    roots: Vec<ModuleRoot>,
193    /// Proto rpcs, for binding calls on generated (OUT_DIR) clients.
194    proto_rpcs: crate::proto_service_bindings::ProtoRpcs<'a>,
195}
196
197/// Module key of a file, manifest-aware: under a discovered package
198/// root, the key is rooted at the *declared package name* (with the
199/// language's self-alias, e.g. Rust's "crate", replaced by it) so that
200/// cross-package imports naming the package match. Outside any root the
201/// plain module_path applies — single-package repos are unchanged.
202fn key_of(spec: &LanguageSpec, roots: &[ModuleRoot], file: &str) -> Vec<String> {
203    let Some((manifest, root)) = spec.manifest.zip(root_of(spec, roots, file)) else {
204        return (spec.module_path)(file);
205    };
206    let rel = if root.dir.is_empty() {
207        file
208    } else {
209        &file[root.dir.len() + 1..]
210    };
211    let mut key = (spec.module_path)(rel);
212    // A declared name may span several segments in reference form
213    // (Go's `module example.com/proj` vs Rust's single-segment crate
214    // name): split it the same way reference paths split.
215    let mut name_segments = vec![root.name.clone()];
216    for sep in spec.path_separators {
217        name_segments = name_segments
218            .iter()
219            .flat_map(|s| s.split(sep).map(str::to_string))
220            .collect();
221    }
222    name_segments.retain(|s| !s.is_empty());
223    match key.first() {
224        Some(head) if manifest.self_names.contains(&head.as_str()) => {
225            key.splice(0..1, name_segments);
226        }
227        _ => {
228            key.splice(0..0, name_segments);
229        }
230    }
231    key
232}
233
234/// Deepest package root containing `file` for this language.
235fn root_of<'r>(spec: &LanguageSpec, roots: &'r [ModuleRoot], file: &str) -> Option<&'r ModuleRoot> {
236    roots
237        .iter()
238        .filter(|r| r.language == spec.name)
239        .filter(|r| r.dir.is_empty() || file.starts_with(&format!("{}/", r.dir)))
240        .max_by_key(|r| r.dir.len())
241}
242
243/// Rewrite a reference path's self-alias head ("crate::x") to the
244/// enclosing package's declared name, so it matches manifest-aware keys.
245fn expand(
246    spec: &LanguageSpec,
247    roots: &[ModuleRoot],
248    file: &str,
249    mut segments: Vec<String>,
250) -> Vec<String> {
251    if let Some(manifest) = spec.manifest
252        && let Some(head) = segments.first()
253        && manifest.self_names.contains(&head.as_str())
254        && let Some(root) = root_of(spec, roots, file)
255    {
256        segments[0] = root.name.clone();
257    }
258    segments
259}
260
261fn module_of(node: &Node, roots: &[ModuleRoot]) -> Vec<String> {
262    let mut module = spec_for_path(&node.file)
263        .map(|s| key_of(s, roots, &node.file))
264        .unwrap_or_default();
265    let qualified = qualified_of(node.id.as_str());
266    if let Some((prefix, _)) = qualified.rsplit_once("::") {
267        module.extend(prefix.split("::").map(str::to_string));
268    }
269    module
270}
271
272/// Per-node data whose computation is independent of every other node —
273/// the expensive half of the index build, computed in parallel.
274struct Prep<'a> {
275    file_module: Vec<String>,
276    qualified: &'a str,
277    prefix: &'a str,
278    functionish: bool,
279    /// file_module + prefix segments, the `by_name` key module.
280    module: Vec<String>,
281}
282
283fn build_index<'a>(
284    nodes: &'a [Node],
285    all_imports: &'a [Reference],
286    locals: &'a [LocalBinding],
287    fields: &'a [FieldBinding],
288    embeds: &'a [Embed],
289    roots: &[ModuleRoot],
290) -> Index<'a> {
291    use rayon::prelude::*;
292    let mut index = Index {
293        by_file_name: HashMap::new(),
294        by_file_qualified: HashMap::new(),
295        file_nodes: HashMap::new(),
296        defs_by_file: HashMap::new(),
297        by_name: HashMap::new(),
298        by_module_tail: HashMap::new(),
299        files_of_module: HashMap::new(),
300        module_defs: HashMap::new(),
301        imports: HashMap::new(),
302        locals: HashMap::new(),
303        fields: HashMap::new(),
304        embeds: HashMap::new(),
305        roots: roots.to_vec(),
306        proto_rpcs: crate::proto_service_bindings::ProtoRpcs::build(nodes),
307    };
308    // Pass 1: qualified -> kind per file, for ancestor-kind checks.
309    let mut kind_of: HashMap<(&str, &str), SymbolKind> = HashMap::new();
310    for node in nodes {
311        kind_of.insert(
312            (node.file.as_str(), qualified_of(node.id.as_str())),
313            node.kind,
314        );
315    }
316    // Pass 2a, parallel: everything derivable from one node alone —
317    // module keys, qualified prefix, lexical visibility — is the hot
318    // part of the build (measured on 1.6M-node corpora). Map insertion
319    // stays serial below, in node order, so the index is byte-identical
320    // to a serial build.
321    let preps: Vec<Option<Prep<'a>>> = nodes
322        .par_iter()
323        .map(|node| {
324            let spec = spec_for_path(&node.file)?;
325            let file_module = key_of(spec, roots, &node.file);
326            if node.kind == SymbolKind::File {
327                return Some(Prep {
328                    file_module,
329                    qualified: "",
330                    prefix: "",
331                    functionish: false,
332                    module: Vec::new(),
333                });
334            }
335            let qualified = qualified_of(node.id.as_str());
336            let prefix = qualified.rsplit_once("::").map_or("", |(p, _)| p);
337            let functionish =
338                prefix
339                    .split("::")
340                    .filter(|s| !s.is_empty())
341                    .try_fold(String::new(), |acc, seg| {
342                        let q = if acc.is_empty() {
343                            seg.to_string()
344                        } else {
345                            format!("{acc}::{seg}")
346                        };
347                        let kind = kind_of.get(&(node.file.as_str(), q.as_str()));
348                        match kind {
349                            Some(k) if is_callable(*k) && *k != SymbolKind::Class => Some(q),
350                            None => None, // impl/receiver scope: not lexically callable
351                            Some(_) => None,
352                        }
353                    });
354            let mut module = file_module.clone();
355            if !prefix.is_empty() {
356                module.extend(prefix.split("::").map(str::to_string));
357            }
358            Some(Prep {
359                file_module,
360                qualified,
361                prefix,
362                functionish: prefix.is_empty() || functionish.is_some(),
363                module,
364            })
365        })
366        .collect();
367    // Pass 2b, serial: insert in node order.
368    for (node, prep) in nodes.iter().zip(preps) {
369        let Some(prep) = prep else {
370            continue;
371        };
372        let file_module = prep.file_module;
373        if node.kind == SymbolKind::File {
374            index.file_nodes.insert(node.file.as_str(), node);
375            if let Some(tail) = file_module.last() {
376                index
377                    .by_module_tail
378                    .entry(tail.clone())
379                    .or_default()
380                    .push((file_module.clone(), node));
381                let entries = index.files_of_module.entry(tail.clone()).or_default();
382                match entries.iter_mut().find(|m| m.key == file_module) {
383                    Some(m) => m.files.push(&node.file),
384                    None => entries.push(ModuleFiles {
385                        key: file_module,
386                        files: vec![&node.file],
387                    }),
388                }
389            }
390            continue;
391        }
392        index
393            .by_file_qualified
394            .insert((node.file.as_str(), prep.qualified), node);
395        index
396            .defs_by_file
397            .entry(node.file.as_str())
398            .or_default()
399            .push(node);
400        index
401            .by_file_name
402            .entry((node.file.as_str(), node.name.as_str()))
403            .or_default()
404            .push(FileDef {
405                node,
406                prefix: prep.prefix,
407                functionish: prep.functionish,
408            });
409        index
410            .by_name
411            .entry(node.name.as_str())
412            .or_default()
413            .push((prep.module, node));
414        if prep.prefix.is_empty() {
415            index
416                .module_defs
417                .entry(file_module)
418                .or_default()
419                .entry(node.name.as_str())
420                .or_default()
421                .push(node);
422        }
423    }
424    for r in all_imports {
425        let Some(spec) = spec_for_path(&r.file) else {
426            continue;
427        };
428        let glob = matches!(r.alias.as_deref(), Some("*") | Some("."));
429        let raw = strip_glob(&r.name);
430        let segments = expand(spec, roots, &r.file, (spec.absolutize)(raw, &r.file));
431        let binding = match (&r.alias, glob) {
432            (Some(alias), false) => alias.clone(),
433            _ => segments.last().cloned().unwrap_or_default(),
434        };
435        index
436            .imports
437            .entry(r.file.as_str())
438            .or_default()
439            .push(Import {
440                segments,
441                binding,
442                glob,
443            });
444    }
445    for l in locals {
446        index
447            .locals
448            .entry((l.file.as_str(), l.name.as_str()))
449            .or_default()
450            .push(LocalRange {
451                start: l.span.start,
452                scope_end: l.scope_end,
453                type_name: l.type_name.as_deref(),
454            });
455    }
456    for field in fields {
457        index
458            .fields
459            .entry(field.owner.as_str())
460            .or_default()
461            .push(field);
462    }
463    for e in embeds {
464        index
465            .embeds
466            .entry(e.owner.as_str())
467            .or_default()
468            .push(&e.type_name);
469    }
470    index
471}
472
473fn strip_glob(name: &str) -> &str {
474    name.strip_suffix('*')
475        .map(|s| s.trim_end_matches(['.', ':', '/']))
476        .unwrap_or(name)
477}
478
479impl<'a> Index<'a> {
480    /// Build the lookup index once; [`resolve`] and [`dynamic_edges`]
481    /// both borrow it, so one pass never builds it twice.
482    pub fn build(
483        nodes: &'a [Node],
484        all_imports: &'a [Reference],
485        locals: &'a [LocalBinding],
486        fields: &'a [FieldBinding],
487        embeds: &'a [Embed],
488        roots: &[ModuleRoot],
489    ) -> Index<'a> {
490        let t = std::time::Instant::now();
491        let index = build_index(nodes, all_imports, locals, fields, embeds, roots);
492        if std::env::var_os("SINTER_TIMING").is_some() {
493            eprintln!("index build: {:?}", t.elapsed());
494        }
495        index
496    }
497
498    /// Local binding in scope at `at`, returning its declared type if any.
499    fn local_at(&self, file: &str, name: &str, at: u64) -> Option<Option<&'a str>> {
500        self.locals
501            .get(&(file, name))
502            .into_iter()
503            .flatten()
504            .filter(|l| l.start <= at && at < l.scope_end)
505            .map(|l| l.type_name)
506            .next_back()
507    }
508
509    /// A type definition visible from `file`: same file, then same module.
510    fn type_def(&self, file: &str, module: &[String], name: &str) -> Option<&'a Node> {
511        let same_file: Vec<&Node> = self
512            .by_file_name
513            .get(&(file, name))
514            .into_iter()
515            .flatten()
516            .filter(|d| is_member_scope(d.node.kind))
517            .map(|d| d.node)
518            .collect();
519        if let [node] = same_file.as_slice() {
520            return Some(node);
521        }
522        let in_module: Vec<&Node> = self
523            .module_defs
524            .get(module)
525            .and_then(|m| m.get(name))
526            .into_iter()
527            .flatten()
528            .filter(|n| is_member_scope(n.kind))
529            .copied()
530            .collect();
531        match in_module.as_slice() {
532            [node] => Some(node),
533            _ => None,
534        }
535    }
536
537    /// Resolve a written type through wrappers and named imports. The
538    /// extractor intentionally preserves source spelling; this tier turns
539    /// `&Dog` and `Arc<dyn Harness>` into corpus type candidates without
540    /// claiming that arbitrary expressions have known types.
541    fn visible_types(&self, file: &str, module: &[String], written: &str) -> Vec<&'a Node> {
542        let mut found = Vec::new();
543        for candidate in type_candidates(written) {
544            if let Some(node) = self.type_def(file, module, candidate) {
545                found.push(node);
546                continue;
547            }
548            let imported: Vec<&Node> = self
549                .imports
550                .get(file)
551                .into_iter()
552                .flatten()
553                .filter(|imp| !imp.glob && imp.binding == candidate)
554                .filter_map(|imp| self.resolve_path_defs(&imp.segments, 4))
555                .filter(|n| is_member_scope(n.kind))
556                .collect();
557            if let [node] = imported.as_slice() {
558                found.push(*node);
559            }
560        }
561        found.sort_by_key(|node| node.id.as_str());
562        found.dedup_by_key(|node| node.id.as_str());
563        found
564    }
565
566    /// Resolve a member through a written receiver type. Multi-trait
567    /// objects (`dyn Read + Seek`) bind only when exactly one visible trait
568    /// owns the member; ambiguity remains unresolved.
569    fn member_of_written_type(
570        &self,
571        file: &str,
572        module: &[String],
573        written: &str,
574        member: &str,
575    ) -> (Option<&'a Node>, bool) {
576        let types = self.visible_types(file, module, written);
577        let mut members: Vec<&Node> = types
578            .iter()
579            .filter_map(|ty| self.member_of(ty, member, 4))
580            .collect();
581        members.sort_by_key(|node| node.id.as_str());
582        members.dedup_by_key(|node| node.id.as_str());
583        let target = match members.as_slice() {
584            [member] => Some(*member),
585            _ => None,
586        };
587        (target, !types.is_empty())
588    }
589
590    fn field(&self, owner: &Node, name: &str) -> Option<&'a FieldBinding> {
591        let matching: Vec<&FieldBinding> = self
592            .fields
593            .get(owner.id.as_str())
594            .into_iter()
595            .flatten()
596            .filter(|f| f.name == name)
597            .copied()
598            .collect();
599        match matching.as_slice() {
600            [field] => Some(*field),
601            _ => None,
602        }
603    }
604
605    /// Member `name` of type `ty`, following embedded types.
606    fn member_of(&self, ty: &'a Node, name: &str, depth: usize) -> Option<&'a Node> {
607        if depth == 0 {
608            return None;
609        }
610        let mut module = module_of(ty, &self.roots);
611        module.extend(
612            qualified_of(ty.id.as_str())
613                .rsplit("::")
614                .next()
615                .map(str::to_string),
616        );
617        let direct: Vec<&Node> = self
618            .by_name
619            .get(name)
620            .into_iter()
621            .flatten()
622            .filter(|(m, _)| *m == module)
623            .map(|(_, n)| *n)
624            .collect();
625        if let [node] = direct.as_slice() {
626            return Some(node);
627        }
628        // Header/impl pairs declare and define the same member in one
629        // module: the declaration inside the type's own file IS the
630        // entity (fixture: cpp-header-impl).
631        let in_type_file: Vec<&Node> = direct
632            .iter()
633            .filter(|n| n.file == ty.file)
634            .copied()
635            .collect();
636        if let [node] = in_type_file.as_slice() {
637            return Some(node);
638        }
639        let spec = spec_for_path(&ty.file)?;
640        let file_module = key_of(spec, &self.roots, &ty.file);
641        for embedded in self.embeds.get(ty.id.as_str()).into_iter().flatten() {
642            if let Some(embedded_ty) = self.type_def(&ty.file, &file_module, embedded)
643                && let Some(node) = self.member_of(embedded_ty, name, depth - 1)
644            {
645                return Some(node);
646            }
647        }
648        None
649    }
650
651    /// Does this path point at anything in the corpus (module suffix
652    /// match or a same-named module part), regardless of unique binding?
653    fn anchored(&self, segments: &[String]) -> bool {
654        let module_hit = |segs: &[String]| {
655            segs.last().is_some_and(|tail| {
656                self.files_of_module
657                    .get(tail.as_str())
658                    .into_iter()
659                    .flatten()
660                    .any(|m| suffix_len(&m.key, segs).is_some())
661                    || self
662                        .by_module_tail
663                        .get(tail.as_str())
664                        .into_iter()
665                        .flatten()
666                        .any(|(key, _)| suffix_len(key, segs).is_some())
667            })
668        };
669        if module_hit(segments) {
670            return true;
671        }
672        match segments.split_last() {
673            Some((_, module)) if !module.is_empty() => module_hit(module),
674            _ => false,
675        }
676    }
677
678    /// File node for an import path, matching either containment
679    /// direction: Go-style (long import, short module key) or
680    /// include-root style (protoc, C headers) where the import resolves
681    /// against roots the graph can't see and the file's repo path ends
682    /// with it. Import-evidence sites only — a bare qualified reference
683    /// must never bind this loosely. Unique or nothing.
684    fn import_file(&self, segments: &[String]) -> Option<&'a Node> {
685        unique_best(
686            self.by_module_tail
687                .get(segments.last()?.as_str())
688                .into_iter()
689                .flatten()
690                .filter_map(|(key, node)| {
691                    let len = suffix_len(key, segments).or_else(|| suffix_len(segments, key))?;
692                    Some((len, *node))
693                }),
694        )
695    }
696
697    /// Resolve absolute segments to a definition or module file node,
698    /// following re-export chains up to a small depth.
699    fn resolve_path(&self, segments: &[String], depth: usize) -> Option<&'a Node> {
700        self.resolve_path_defs(segments, depth).or_else(|| {
701            // Module/package: bind to its file node.
702            // ponytail: single-file packages only; multi-file packages stay
703            // unresolved here — bind-to-all-files when a consumer needs it.
704            let files = self
705                .by_module_tail
706                .get(segments.last()?.as_str())
707                .into_iter()
708                .flatten()
709                .filter_map(|(key, node)| Some((suffix_len(key, segments)?, *node)));
710            unique_best(files)
711        })
712    }
713
714    /// Like [`resolve_path`] but definitions only — a qualified call or
715    /// use must never bind to an unrelated module *file* through the
716    /// loose tail fallback (a Rust `hooks::install()` once bound to a
717    /// bash `install.sh` this way); the file fallback is import-context
718    /// evidence.
719    fn resolve_path_defs(&self, segments: &[String], depth: usize) -> Option<&'a Node> {
720        if segments.is_empty() || depth == 0 {
721            return None;
722        }
723        if let Some((name, module)) = segments.split_last() {
724            let defs = self
725                .by_name
726                .get(name.as_str())
727                .into_iter()
728                .flatten()
729                .filter_map(|(key, node)| Some((suffix_len(key, module)?, *node)));
730            if let Some(node) = unique_best(defs) {
731                return Some(node);
732            }
733            // Re-export chain: the module part names files that re-export
734            // this name — follow their imports.
735            if !module.is_empty() {
736                let mut chained: Vec<&Node> = Vec::new();
737                let tail = module.last().map(String::as_str).unwrap_or("");
738                for m in self.files_of_module.get(tail).into_iter().flatten() {
739                    if suffix_len(&m.key, module).is_none() {
740                        continue;
741                    }
742                    for file in &m.files {
743                        for import in self.imports.get(*file).into_iter().flatten() {
744                            if import.binding == *name && !import.glob {
745                                chained.extend(self.resolve_path(&import.segments, depth - 1));
746                            } else if import.glob {
747                                let mut deeper = import.segments.clone();
748                                deeper.push(name.clone());
749                                chained.extend(self.resolve_path(&deeper, depth - 1));
750                            }
751                        }
752                    }
753                }
754                chained.sort_by_key(|n| n.id.as_str().to_string());
755                chained.dedup_by_key(|n| n.id.as_str().to_string());
756                if let [node] = chained.as_slice() {
757                    return Some(node);
758                }
759            }
760        }
761        None
762    }
763}
764
765/// Plausible type identifiers, inner-most first. Resolution still requires
766/// a unique corpus definition, so a generic with several type arguments
767/// remains unresolved unless exactly one candidate owns the requested
768/// member.
769const TYPE_KEYWORDS: &[&str] = &[
770    "dyn", "impl", "mut", "const", "ref", "crate", "self", "super", "std", "core", "alloc",
771];
772
773fn type_tokens(text: &str) -> impl DoubleEndedIterator<Item = &str> {
774    text.split(|c: char| !(c.is_alphanumeric() || c == '_'))
775        .filter(|token| {
776            !token.is_empty()
777                && !token.chars().next().is_some_and(char::is_numeric)
778                && !TYPE_KEYWORDS.contains(token)
779        })
780}
781
782fn type_candidates(written: &str) -> Vec<&str> {
783    // These wrappers implement transparent receiver dereference. Containers
784    // such as Option/Result/Vec/Mutex deliberately stay outer types: binding
785    // their method calls to the element type would create false edges.
786    const DEREF_WRAPPERS: &[&str] = &["Box", "Arc", "Rc", "Pin", "Cow"];
787    let (head_text, arguments) = written
788        .split_once('<')
789        .map_or((written, None), |(head, rest)| (head, Some(rest)));
790    let head = type_tokens(head_text).next_back();
791    if let Some(head) = head
792        && !DEREF_WRAPPERS.contains(&head)
793    {
794        return vec![head];
795    }
796    let mut out = Vec::new();
797    for token in type_tokens(arguments.unwrap_or(written)).rev() {
798        if DEREF_WRAPPERS.contains(&token) {
799            continue;
800        }
801        if !out.contains(&token) {
802            out.push(token);
803        }
804    }
805    out
806}
807
808/// Pick among same-name candidates: a call prefers callables, a use prefers
809/// types (value vs type namespace). Applied only on ambiguity.
810fn namespace_pick(candidates: Vec<&Node>, relation: Relation) -> Option<&Node> {
811    match candidates.as_slice() {
812        [node] => Some(node),
813        [] => None,
814        _ => {
815            let preferred: Vec<&Node> = candidates
816                .iter()
817                .filter(|n| match relation {
818                    Relation::Calls => is_callable(n.kind),
819                    Relation::Uses => is_type_kind(n.kind),
820                    _ => true,
821                })
822                .copied()
823                .collect();
824            match preferred.as_slice() {
825                [node] => Some(node),
826                _ => None,
827            }
828        }
829    }
830}
831
832pub fn resolve(
833    index: &Index<'_>,
834    references: &[Reference],
835) -> (Vec<Binding>, ResolutionStats, Vec<usize>) {
836    use rayon::prelude::*;
837    let results: Vec<Res> = references
838        .par_iter()
839        .enumerate()
840        .map(|(i, r)| {
841            let Some(spec) = spec_for_path(&r.file) else {
842                return Res::External;
843            };
844            let src = r
845                .enclosing
846                .clone()
847                .unwrap_or_else(|| NodeId::new(r.file.clone()));
848            let file_module = key_of(spec, &index.roots, &r.file);
849            let imports = index.imports.get(r.file.as_str());
850            let (target, evidence, internal) = resolve_one(index, spec, r, &file_module, imports);
851            match target {
852                Some(node) if node.id != src => {
853                    // A "call" landing on a type is a conversion or
854                    // instantiation of a non-callable kind: it is a use.
855                    let relation = if r.relation == Relation::Calls && is_type_kind(node.kind) {
856                        Relation::Uses
857                    } else {
858                        r.relation
859                    };
860                    Res::Bound(Binding {
861                        edge: Edge {
862                            src,
863                            dst: node.id.clone(),
864                            relation,
865                            evidence,
866                            // Convention-bound (proto client) references
867                            // are declared but never compiler-checked.
868                            confidence: if evidence == Evidence::Declared {
869                                Confidence::Inferred
870                            } else {
871                                evidence.confidence()
872                            },
873                            site: Some(r.span),
874                        },
875                        reference: i,
876                    })
877                }
878                _ if internal => Res::Internal,
879                _ => Res::External,
880            }
881        })
882        .collect();
883    let mut bindings = Vec::new();
884    let mut stats = ResolutionStats::default();
885    let mut internal_indices = Vec::new();
886    for (i, result) in results.into_iter().enumerate() {
887        match result {
888            Res::Bound(binding) => {
889                match binding.edge.evidence {
890                    Evidence::Scope => stats.scope += 1,
891                    _ => stats.import += 1,
892                }
893                bindings.push(binding);
894            }
895            Res::Internal => {
896                stats.unresolved_internal += 1;
897                internal_indices.push(i);
898            }
899            Res::External => stats.unresolved_external += 1,
900        }
901    }
902    (bindings, stats, internal_indices)
903}
904
905fn resolve_one<'a>(
906    index: &Index<'a>,
907    spec: &sinter_extract::LanguageSpec,
908    r: &Reference,
909    file_module: &[String],
910    imports: Option<&Vec<Import>>,
911) -> (Option<&'a Node>, Evidence, bool) {
912    if r.relation == Relation::Imports {
913        return resolve_import_reference(index, spec, r);
914    }
915
916    if let Some(path) = &r.path {
917        let (target, evidence, internal) =
918            resolve_qualified_reference(index, spec, r, file_module, imports, path);
919        if target.is_none()
920            && r.relation == Relation::Calls
921            && !index.proto_rpcs.is_empty()
922            && let Some(rpc) = proto_client_call(index, spec, r, file_module, imports, path)
923        {
924            return (Some(rpc), Evidence::Declared, true);
925        }
926        return (target, evidence, internal);
927    }
928
929    resolve_bare_reference(index, r, file_module, imports)
930}
931
932/// Method call on a tonic-generated client (`client.adjudicates(req)`):
933/// the client type never exists in the corpus, so the call binds to the
934/// proto rpc by convention. Receiver type comes from a typed local or a
935/// declared field; otherwise the file's imports must name the client.
936fn proto_client_call<'a>(
937    index: &Index<'a>,
938    spec: &LanguageSpec,
939    r: &Reference,
940    file_module: &[String],
941    imports: Option<&Vec<Import>>,
942    path: &str,
943) -> Option<&'a Node> {
944    let segments = expand(
945        spec,
946        &index.roots,
947        &r.file,
948        (spec.absolutize)(path, &r.file),
949    );
950    let prefix = segments.get(segments.len().checked_sub(2)?)?;
951    let field_type = || {
952        let enclosing = r.enclosing.as_ref()?;
953        let (type_prefix, _) = qualified_of(enclosing.as_str()).rsplit_once("::")?;
954        let name = type_prefix.rsplit("::").next().unwrap_or(type_prefix);
955        let owner = index
956            .by_file_qualified
957            .get(&(r.file.as_str(), type_prefix))
958            .copied()
959            .or_else(|| index.type_def(&r.file, file_module, name))?;
960        Some(index.field(owner, prefix)?.type_name.as_str())
961    };
962    let receiver_type = if segments.len() >= 3
963        && spec
964            .receivers
965            .contains(&segments[segments.len() - 3].as_str())
966    {
967        field_type()
968    } else {
969        index.local_at(&r.file, prefix, r.span.start).flatten()
970    };
971    let tokens = imports.into_iter().flatten().flat_map(|imp| {
972        imp.segments
973            .iter()
974            .map(String::as_str)
975            .chain([imp.binding.as_str()])
976    });
977    index.proto_rpcs.client_call(&r.name, receiver_type, tokens)
978}
979
980/// Import declarations resolve through exact files first, then absolute
981/// module/definition paths. A corpus-anchored miss remains internal.
982fn resolve_import_reference<'a>(
983    index: &Index<'a>,
984    spec: &LanguageSpec,
985    r: &Reference,
986) -> (Option<&'a Node>, Evidence, bool) {
987    let glob = matches!(r.alias.as_deref(), Some("*") | Some("."));
988    let raw = strip_glob(&r.name);
989    // An import naming a literal repo file binds it exactly — this is
990    // how `#include "player/character.h"` stays unambiguous even though
991    // header and impl share one module (fixture: cpp-header-impl).
992    if let Some(node) = index
993        .file_nodes
994        .get(raw.trim().trim_matches(['<', '>', '"']))
995    {
996        return (Some(node), Evidence::Import, true);
997    }
998    let segments = expand(spec, &index.roots, &r.file, (spec.absolutize)(raw, &r.file));
999    let target = if glob {
1000        index.import_file(&segments)
1001    } else {
1002        index.resolve_path(&segments, 4)
1003    };
1004    let internal = target.is_some() || index.anchored(&segments);
1005    (target, Evidence::Import, internal)
1006}
1007
1008/// Qualified references resolve receiver and type evidence before absolute
1009/// paths and named imports. The tier ordering is part of the binding contract.
1010fn resolve_qualified_reference<'a>(
1011    index: &Index<'a>,
1012    spec: &LanguageSpec,
1013    r: &Reference,
1014    file_module: &[String],
1015    imports: Option<&Vec<Import>>,
1016    path: &str,
1017) -> (Option<&'a Node>, Evidence, bool) {
1018    // Document-path languages (spec.file_refs): the path names a
1019    // corpus file, never a symbol — dedicated tier, no fallthrough.
1020    if spec.file_refs {
1021        return resolve_file_ref(index, spec, r, path);
1022    }
1023    // Qualified reference: receiver, typed local, shadow, absolute
1024    // path, then imports — strongest local knowledge first.
1025    let segments = expand(
1026        spec,
1027        &index.roots,
1028        &r.file,
1029        (spec.absolutize)(path, &r.file),
1030    );
1031    let prefix = segments
1032        .len()
1033        .checked_sub(2)
1034        .and_then(|p| segments.get(p))
1035        .cloned();
1036    let Some(prefix) = prefix else {
1037        return (None, Evidence::Import, false);
1038    };
1039    // Field receiver: `self.harness.check()`. The ordinary receiver
1040    // tier sees `harness` as the prefix, so it cannot use the enclosing
1041    // impl type. A declared field type provides the missing link.
1042    if segments.len() >= 3
1043        && spec
1044            .receivers
1045            .contains(&segments[segments.len() - 3].as_str())
1046        && let Some(enclosing) = &r.enclosing
1047        && let Some((type_prefix, _)) = qualified_of(enclosing.as_str()).rsplit_once("::")
1048    {
1049        let owner = index
1050            .by_file_qualified
1051            .get(&(r.file.as_str(), type_prefix))
1052            .copied()
1053            .or_else(|| {
1054                let name = type_prefix.rsplit("::").next().unwrap_or(type_prefix);
1055                index.type_def(&r.file, file_module, name)
1056            });
1057        if let Some(owner) = owner
1058            && let Some(field) = index.field(owner, &segments[segments.len() - 2])
1059        {
1060            let field_spec = spec_for_path(&owner.file).unwrap_or(spec);
1061            let field_module = key_of(field_spec, &index.roots, &owner.file);
1062            let (target, anchored) =
1063                index.member_of_written_type(&owner.file, &field_module, &field.type_name, &r.name);
1064            return (target, Evidence::Scope, anchored);
1065        }
1066    }
1067    if spec.receivers.contains(&prefix.as_str())
1068        && let Some(enclosing) = &r.enclosing
1069        && let Some((type_prefix, _)) = qualified_of(enclosing.as_str()).rsplit_once("::")
1070    {
1071        // Sibling method in the same impl block's file: `self.m()`
1072        // inside `impl T` binds `T::m` without needing T's definition
1073        // in this file (struct in types.rs, impl in lib.rs).
1074        let sibling = format!("{type_prefix}::{}", r.name);
1075        if let Some(node) = index
1076            .by_file_qualified
1077            .get(&(r.file.as_str(), sibling.as_str()))
1078        {
1079            return (Some(node), Evidence::Scope, true);
1080        }
1081        if let Some(ty) = index.by_file_qualified.get(&(r.file.as_str(), type_prefix)) {
1082            // Receiver type is in the corpus: any miss is internal.
1083            return (index.member_of(ty, &r.name, 4), Evidence::Scope, true);
1084        }
1085    }
1086    match index.local_at(&r.file, &prefix, r.span.start) {
1087        Some(Some(type_name)) => {
1088            let (target, anchored) =
1089                index.member_of_written_type(&r.file, file_module, type_name, &r.name);
1090            // Known corpus type but missing member -> internal.
1091            return (target, Evidence::Scope, anchored);
1092        }
1093        Some(None) => return (None, Evidence::Scope, false), // shadowed: correctly no edge
1094        None => {}
1095    }
1096    // Same-scope type qualifier (Counter::new in the type's own file).
1097    if let Some(ty) = index.type_def(&r.file, file_module, &prefix)
1098        && let Some(node) = index.member_of(ty, &r.name, 4)
1099    {
1100        return (Some(node), Evidence::Scope, true);
1101    }
1102    if let Some(node) = index.resolve_path_defs(&segments, 4) {
1103        return (Some(node), Evidence::Import, true);
1104    }
1105    // Associated item through a path: the second-to-last segment is a
1106    // *type*, not a module (`some_crate::Config::new`,
1107    // `ns::Class::method`). Resolve the prefix as a path — re-export
1108    // chains included — then look the leaf up as a member. Path
1109    // shape, not language shape: active for every language.
1110    if let Some((leaf, type_path)) = segments.split_last()
1111        && type_path.len() >= 2
1112        && let Some(ty) = index.resolve_path_defs(type_path, 4)
1113        && let Some(node) = index.member_of(ty, leaf, 4)
1114    {
1115        return (Some(node), Evidence::Import, true);
1116    }
1117    let matching: Vec<&Import> = imports
1118        .into_iter()
1119        .flatten()
1120        .filter(|imp| !imp.glob && imp.binding == prefix)
1121        .collect();
1122    let candidates: Vec<&Node> = matching
1123        .iter()
1124        .filter_map(|imp| {
1125            let mut full = imp.segments.clone();
1126            full.push(r.name.clone());
1127            index.resolve_path(&full, 4)
1128        })
1129        .collect();
1130    let internal = candidates.len() > 1
1131        || index.anchored(&segments)
1132        || matching.iter().any(|imp| index.anchored(&imp.segments));
1133    match candidates.as_slice() {
1134        [node] => (Some(node), Evidence::Import, true),
1135        _ => (None, Evidence::Import, internal),
1136    }
1137}
1138
1139/// Bare names resolve lexical scope and module scope before named and glob
1140/// imports. Shadowing and every ambiguity remain evidence-or-nothing.
1141fn resolve_bare_reference<'a>(
1142    index: &Index<'a>,
1143    r: &Reference,
1144    file_module: &[String],
1145    imports: Option<&Vec<Import>>,
1146) -> (Option<&'a Node>, Evidence, bool) {
1147    if index.local_at(&r.file, &r.name, r.span.start).is_some() {
1148        return (None, Evidence::Scope, false); // shadowed: correctly no edge
1149    }
1150    let enclosing_q = r
1151        .enclosing
1152        .as_ref()
1153        .map(|e| qualified_of(e.as_str()))
1154        .unwrap_or("");
1155    let visible: Vec<&Node> = index
1156        .by_file_name
1157        .get(&(r.file.as_str(), r.name.as_str()))
1158        .into_iter()
1159        .flatten()
1160        .filter(|d| {
1161            d.prefix.is_empty()
1162                || (d.functionish
1163                    && (enclosing_q == d.prefix
1164                        || enclosing_q.starts_with(&format!("{}::", d.prefix))))
1165        })
1166        .map(|d| d.node)
1167        .collect();
1168    if !visible.is_empty() {
1169        // Candidates exist in scope: a miss here is ambiguity — internal.
1170        return (namespace_pick(visible, r.relation), Evidence::Scope, true);
1171    }
1172    if let Some(defs) = index
1173        .module_defs
1174        .get(file_module)
1175        .and_then(|m| m.get(r.name.as_str()))
1176    {
1177        return (
1178            namespace_pick(defs.clone(), r.relation),
1179            Evidence::Scope,
1180            true,
1181        );
1182    }
1183    let named: Vec<&Node> = imports
1184        .into_iter()
1185        .flatten()
1186        .filter(|imp| !imp.glob && imp.binding == r.name)
1187        .filter_map(|imp| index.resolve_path(&imp.segments, 4))
1188        .collect();
1189    let (target, internal) = match named.as_slice() {
1190        [node] => (Some(*node), true),
1191        [] => {
1192            let globbed: Vec<&Node> = imports
1193                .into_iter()
1194                .flatten()
1195                .filter(|imp| imp.glob)
1196                .filter_map(|imp| {
1197                    let mut full = imp.segments.clone();
1198                    full.push(r.name.clone());
1199                    index.resolve_path(&full, 4).or_else(|| {
1200                        // Include-root import: bind via the imported
1201                        // file's own top-level definitions.
1202                        let file = index.import_file(&imp.segments)?;
1203                        index
1204                            .by_file_name
1205                            .get(&(file.file.as_str(), r.name.as_str()))
1206                            .into_iter()
1207                            .flatten()
1208                            .find(|d| d.prefix.is_empty())
1209                            .map(|d| d.node)
1210                    })
1211                })
1212                .collect();
1213            let name_imports_anchored = imports
1214                .into_iter()
1215                .flatten()
1216                .filter(|imp| !imp.glob && imp.binding == r.name)
1217                .any(|imp| index.anchored(&imp.segments));
1218            match globbed.as_slice() {
1219                [node] => (Some(*node), true),
1220                [] => (None, name_imports_anchored),
1221                _ => (None, true), // glob ambiguity across corpus modules
1222            }
1223        }
1224        _ => (None, true), // ambiguous named imports
1225    };
1226    (target, Evidence::Import, internal)
1227}
1228
1229/// Document-path reference (spec.file_refs, e.g. a markdown link): the
1230/// path resolves to a corpus file — the same exact-file evidence imports
1231/// carry — with the language's extensions optional and `#fragment`
1232/// binding the target file's unique def whose name slugifies to the
1233/// fragment (`#quality-gate` -> the "Quality Gate" section). A path that
1234/// names no corpus file is a dead or external link and stays unresolved:
1235/// evidence or nothing, never a guess.
1236fn resolve_file_ref<'a>(
1237    index: &Index<'a>,
1238    spec: &LanguageSpec,
1239    r: &Reference,
1240    path: &str,
1241) -> (Option<&'a Node>, Evidence, bool) {
1242    let (head, frag) = match path.split_once('#') {
1243        Some((h, f)) => (h, Some(f)),
1244        None => (path, None),
1245    };
1246    let file = if head.is_empty() {
1247        // `#fragment` alone: the linking file itself.
1248        index.file_nodes.get(r.file.as_str()).copied()
1249    } else {
1250        let joined = (spec.absolutize)(head, &r.file).join("/");
1251        index.file_nodes.get(joined.as_str()).copied().or_else(|| {
1252            spec.extensions.iter().find_map(|ext| {
1253                index
1254                    .file_nodes
1255                    .get(format!("{joined}.{ext}").as_str())
1256                    .copied()
1257            })
1258        })
1259    };
1260    match (file, frag) {
1261        (Some(file), None) => (Some(file), Evidence::Import, true),
1262        (Some(file), Some(frag)) => {
1263            let matching: Vec<&Node> = index
1264                .defs_by_file
1265                .get(file.file.as_str())
1266                .into_iter()
1267                .flatten()
1268                .filter(|n| slugify(&n.name) == frag)
1269                .copied()
1270                .collect();
1271            // The file is corpus evidence: a fragment miss (or a
1272            // duplicate slug) is internal, and unique-or-nothing holds.
1273            match matching.as_slice() {
1274                [node] => (Some(node), Evidence::Import, true),
1275                _ => (None, Evidence::Import, true),
1276            }
1277        }
1278        (None, _) => (None, Evidence::Import, false),
1279    }
1280}
1281
1282/// GitHub-style heading slug: lowercase, spaces become dashes, `-`/`_`
1283/// survive, other punctuation drops.
1284fn slugify(name: &str) -> String {
1285    name.chars()
1286        .filter_map(|c| match c {
1287            ' ' => Some('-'),
1288            '-' | '_' => Some(c),
1289            c if c.is_alphanumeric() => Some(c.to_ascii_lowercase()),
1290            _ => None,
1291        })
1292        .collect()
1293}
1294
1295/// Dynamic-dispatch fan-out edges: for every impl block naming a trait the
1296/// corpus defines, `trait_method -> impl_method` (Calls, Dynamic) for each
1297/// method the impl defines under a same-named trait method. Conservative
1298/// over-approximation — every impl is assumed reachable through the trait —
1299/// which is exactly why the edges carry the distinct Dynamic evidence.
1300/// Pairing rule: the impl block names the trait (same file/module, or a
1301/// named import) and the method names match.
1302pub fn dynamic_edges(index: &Index<'_>, nodes: &[Node], trait_impls: &[TraitImpl]) -> Vec<Edge> {
1303    // Proto service conventions ride the same post-resolution slot: they
1304    // need nodes and impl blocks, nothing from reference resolution.
1305    let mut edges = crate::proto_service_bindings::proto_service_edges(nodes, trait_impls);
1306    let implicit = nodes
1307        .iter()
1308        .any(|n| spec_for_path(&n.file).is_some_and(|s| s.implicit_interfaces));
1309    if trait_impls.is_empty() && !implicit {
1310        return edges;
1311    }
1312    let roots = &index.roots;
1313    let mut by_file: HashMap<&str, Vec<&Node>> = HashMap::new();
1314    let mut types_by_file: HashMap<&str, Vec<&Node>> = HashMap::new();
1315    for n in nodes {
1316        if is_callable(n.kind) {
1317            by_file.entry(n.file.as_str()).or_default().push(n);
1318        }
1319        if is_member_scope(n.kind) {
1320            types_by_file.entry(n.file.as_str()).or_default().push(n);
1321        }
1322    }
1323    // Class included: C# captures base classes as @trait because its
1324    // virtual dispatch flows through them; Rust/Java only ever emit
1325    // @trait on real traits/interfaces, so they are unaffected.
1326    let is_trait = |n: &Node| {
1327        matches!(
1328            n.kind,
1329            SymbolKind::Trait | SymbolKind::Interface | SymbolKind::Class
1330        )
1331    };
1332    for ti in trait_impls {
1333        let Some(spec) = spec_for_path(&ti.file) else {
1334            continue;
1335        };
1336        let file_module = key_of(spec, roots, &ti.file);
1337        let trait_node = index
1338            .type_def(&ti.file, &file_module, &ti.trait_name)
1339            .filter(|n| is_trait(n))
1340            .map(|n| (n, Evidence::Scope))
1341            .or_else(|| {
1342                // Trait bound through a named import; unique or nothing.
1343                let named: Vec<&Node> = index
1344                    .imports
1345                    .get(ti.file.as_str())
1346                    .into_iter()
1347                    .flatten()
1348                    .filter(|imp| !imp.glob && imp.binding == ti.trait_name)
1349                    .filter_map(|imp| index.resolve_path_defs(&imp.segments, 4))
1350                    .filter(|n| is_trait(n))
1351                    .collect();
1352                match named.as_slice() {
1353                    [node] => Some((node, Evidence::Import)),
1354                    _ => None,
1355                }
1356            })
1357            .or_else(|| {
1358                // Glob imports (C++ #include, C# using): the trait is one
1359                // of the module's top-level names; unique or nothing.
1360                let globbed: Vec<&Node> = index
1361                    .imports
1362                    .get(ti.file.as_str())
1363                    .into_iter()
1364                    .flatten()
1365                    .filter(|imp| imp.glob)
1366                    .filter_map(|imp| {
1367                        let mut full = imp.segments.clone();
1368                        full.push(ti.trait_name.clone());
1369                        index.resolve_path_defs(&full, 4)
1370                    })
1371                    .filter(|n| is_trait(n))
1372                    .collect();
1373                match globbed.as_slice() {
1374                    [node] => Some((node, Evidence::Import)),
1375                    _ => None,
1376                }
1377            });
1378        let Some((trait_node, pair_evidence)) = trait_node else {
1379            continue; // external trait: nothing in the corpus to fan into
1380        };
1381        let mut impl_methods: Vec<&Node> = Vec::new();
1382        for method in by_file.get(ti.file.as_str()).into_iter().flatten() {
1383            if !(ti.span.start <= method.span.start && method.span.end <= ti.span.end) {
1384                continue;
1385            }
1386            impl_methods.push(method);
1387            if let Some(trait_method) = index.member_of(trait_node, &method.name, 1)
1388                && trait_method.id != method.id
1389            {
1390                edges.push(Edge {
1391                    src: trait_method.id.clone(),
1392                    dst: method.id.clone(),
1393                    relation: Relation::Calls,
1394                    evidence: Evidence::Dynamic,
1395                    confidence: Evidence::Dynamic.confidence(),
1396                    // Fan-out is assumed, not written anywhere: no site.
1397                    site: None,
1398                });
1399            }
1400        }
1401        // Persistent supertype edge, impl type -> trait/base. The block
1402        // either IS the implementing type's declaration (class languages)
1403        // or contains its methods (Rust impl blocks) — the method prefix
1404        // then names the type. Same kinds mean inheritance (class : class,
1405        // interface extends interface); differing kinds mean an interface
1406        // contract. Evidence mirrors how the pairing was bound.
1407        let impl_type = types_by_file
1408            .get(ti.file.as_str())
1409            .into_iter()
1410            .flatten()
1411            .find(|n| n.span == ti.span)
1412            .copied()
1413            .or_else(|| {
1414                let prefix = impl_methods.iter().find_map(|m| {
1415                    let q = qualified_of(m.id.as_str());
1416                    q.rsplit_once("::")
1417                        .map(|(p, _)| p.rsplit("::").next().unwrap_or(p))
1418                })?;
1419                index.type_def(&ti.file, &file_module, prefix)
1420            });
1421        if let Some(impl_type) = impl_type
1422            && impl_type.id != trait_node.id
1423        {
1424            let relation = if impl_type.kind == trait_node.kind {
1425                Relation::Extends
1426            } else {
1427                Relation::Implements
1428            };
1429            edges.push(Edge {
1430                src: impl_type.id.clone(),
1431                dst: trait_node.id.clone(),
1432                relation,
1433                evidence: pair_evidence,
1434                confidence: pair_evidence.confidence(),
1435                // The impl block's span lives in ti.file, which may not be
1436                // the impl type's file — a site here could point into the
1437                // wrong file, so none is carried.
1438                site: None,
1439            });
1440        }
1441    }
1442    if implicit {
1443        edges.extend(implicit_interface_edges(nodes, roots));
1444    }
1445    edges.sort();
1446    edges.dedup();
1447    edges
1448}
1449
1450/// Structural interface satisfaction for languages where no syntax names
1451/// the interface at the implementing type (spec.implicit_interfaces — Go):
1452/// within one package, a type T satisfies interface I when T's method
1453/// names cover all of I's declared methods. Name-only matching
1454/// over-approximates signatures, so every edge carries Dynamic evidence
1455/// (Inferred, excludable). Package scope keeps precision high: matching
1456/// the whole corpus would pair unrelated same-shaped types.
1457/// ponytail: cross-package satisfaction (io.Writer style) not inferred;
1458/// widen to module scope if a real repo shows the recall gap.
1459fn implicit_interface_edges(nodes: &[Node], roots: &[ModuleRoot]) -> Vec<Edge> {
1460    // (package key, type name) -> type nodes; (package key, type name) ->
1461    // methods declared/received under that name.
1462    let mut types: HashMap<(Vec<String>, &str), Vec<&Node>> = HashMap::new();
1463    let mut types_by_key: HashMap<Vec<String>, Vec<&Node>> = HashMap::new();
1464    let mut methods: HashMap<(Vec<String>, &str), Vec<&Node>> = HashMap::new();
1465    for n in nodes {
1466        let Some(spec) = spec_for_path(&n.file) else {
1467            continue;
1468        };
1469        if !spec.implicit_interfaces {
1470            continue;
1471        }
1472        let key = key_of(spec, roots, &n.file);
1473        match n.kind {
1474            SymbolKind::Interface | SymbolKind::Struct | SymbolKind::TypeAlias => {
1475                types
1476                    .entry((key.clone(), n.name.as_str()))
1477                    .or_default()
1478                    .push(n);
1479                types_by_key.entry(key).or_default().push(n);
1480            }
1481            SymbolKind::Method => {
1482                let q = qualified_of(n.id.as_str());
1483                if let Some((owner, _)) = q.rsplit_once("::")
1484                    && !owner.contains("::")
1485                {
1486                    methods.entry((key, owner)).or_default().push(n);
1487                }
1488            }
1489            _ => {}
1490        }
1491    }
1492    let mut edges = Vec::new();
1493    for ((key, name), candidates) in &types {
1494        // Unique or nothing: a same-named sibling makes ownership ambiguous.
1495        let [iface] = candidates.as_slice() else {
1496            continue;
1497        };
1498        if iface.kind != SymbolKind::Interface {
1499            continue;
1500        }
1501        let Some(iface_methods) = methods.get(&(key.clone(), *name)) else {
1502            continue; // empty interface: everything satisfies it — emit nothing
1503        };
1504        for ty in types_by_key.get(key).into_iter().flatten() {
1505            if ty.kind == SymbolKind::Interface {
1506                continue;
1507            }
1508            let ty_methods = methods.get(&(key.clone(), ty.name.as_str()));
1509            let covers = |m: &Node| ty_methods.into_iter().flatten().any(|tm| tm.name == m.name);
1510            if !iface_methods.iter().all(|m| covers(m)) {
1511                continue;
1512            }
1513            for im in iface_methods {
1514                for tm in ty_methods.into_iter().flatten() {
1515                    if tm.name == im.name {
1516                        edges.push(Edge {
1517                            src: im.id.clone(),
1518                            dst: tm.id.clone(),
1519                            relation: Relation::Calls,
1520                            evidence: Evidence::Dynamic,
1521                            confidence: Evidence::Dynamic.confidence(),
1522                            site: None,
1523                        });
1524                    }
1525                }
1526            }
1527            edges.push(Edge {
1528                src: ty.id.clone(),
1529                dst: iface.id.clone(),
1530                relation: Relation::Implements,
1531                evidence: Evidence::Dynamic,
1532                confidence: Evidence::Dynamic.confidence(),
1533                site: None,
1534            });
1535        }
1536    }
1537    edges
1538}
1539
1540/// Resolve references against FOREIGN definitions using import evidence
1541/// only — the cross-repo boundary pass. Same-file/module/receiver/local
1542/// tiers are intra-repo by definition and deliberately excluded, which
1543/// also prevents false bindings between identically-named files in
1544/// different members. `refs` and `owner_imports` come from one member;
1545/// `foreign_nodes` from the others.
1546pub fn resolve_boundary(
1547    foreign_nodes: &[Node],
1548    references: &[Reference],
1549    owner_imports: &[Reference],
1550) -> Vec<Binding> {
1551    let index = build_index(foreign_nodes, owner_imports, &[], &[], &[], &[]);
1552    let mut bindings = Vec::new();
1553    for (i, r) in references.iter().enumerate() {
1554        let Some(spec) = spec_for_path(&r.file) else {
1555            continue;
1556        };
1557        let src = r
1558            .enclosing
1559            .clone()
1560            .unwrap_or_else(|| NodeId::new(r.file.clone()));
1561        let imports = index.imports.get(r.file.as_str());
1562        let target = if r.relation == Relation::Imports {
1563            let glob = matches!(r.alias.as_deref(), Some("*") | Some("."));
1564            let segments = (spec.absolutize)(strip_glob(&r.name), &r.file);
1565            if glob {
1566                index.import_file(&segments)
1567            } else {
1568                index.resolve_path(&segments, 4)
1569            }
1570        } else if let Some(path) = &r.path {
1571            let segments = (spec.absolutize)(path, &r.file);
1572            let direct = index.resolve_path(&segments, 4);
1573            direct.or_else(|| {
1574                let prefix = segments
1575                    .len()
1576                    .checked_sub(2)
1577                    .and_then(|p| segments.get(p))?;
1578                let candidates: Vec<&Node> = imports
1579                    .into_iter()
1580                    .flatten()
1581                    .filter(|imp| !imp.glob && imp.binding == *prefix)
1582                    .filter_map(|imp| {
1583                        let mut full = imp.segments.clone();
1584                        full.push(r.name.clone());
1585                        index.resolve_path(&full, 4)
1586                    })
1587                    .collect();
1588                match candidates.as_slice() {
1589                    [node] => Some(node),
1590                    _ => None,
1591                }
1592            })
1593        } else {
1594            // Bare name: only through this member's own imports.
1595            let named: Vec<&Node> = imports
1596                .into_iter()
1597                .flatten()
1598                .filter(|imp| !imp.glob && imp.binding == r.name)
1599                .filter_map(|imp| index.resolve_path(&imp.segments, 4))
1600                .collect();
1601            match named.as_slice() {
1602                [node] => Some(*node),
1603                _ => None,
1604            }
1605        };
1606        if let Some(node) = target
1607            && node.id != src
1608        {
1609            let relation = if r.relation == Relation::Calls && is_type_kind(node.kind) {
1610                Relation::Uses
1611            } else {
1612                r.relation
1613            };
1614            bindings.push(Binding {
1615                edge: Edge {
1616                    src,
1617                    dst: node.id.clone(),
1618                    relation,
1619                    evidence: Evidence::Import,
1620                    confidence: Evidence::Import.confidence(),
1621                    site: Some(r.span),
1622                },
1623                reference: i,
1624            });
1625        }
1626    }
1627    bindings
1628}
1629
1630/// Segment count of `key` if it is a non-empty suffix of `path`.
1631fn suffix_len(key: &[String], path: &[String]) -> Option<usize> {
1632    (!key.is_empty() && path.len() >= key.len() && path[path.len() - key.len()..] == key[..])
1633        .then_some(key.len())
1634}
1635
1636/// Of the longest-key candidates, the single node — or None on ambiguity.
1637fn unique_best<'a>(candidates: impl Iterator<Item = (usize, &'a Node)>) -> Option<&'a Node> {
1638    let mut best: Option<(usize, Vec<&Node>)> = None;
1639    for (len, node) in candidates {
1640        match &mut best {
1641            Some((best_len, nodes)) if len == *best_len => nodes.push(node),
1642            Some((best_len, nodes)) if len > *best_len => {
1643                *best_len = len;
1644                nodes.clear();
1645                nodes.push(node);
1646            }
1647            None => best = Some((len, vec![node])),
1648            _ => {}
1649        }
1650    }
1651    match best {
1652        Some((_, nodes)) if nodes.len() == 1 => Some(nodes[0]),
1653        _ => None,
1654    }
1655}
1656
1657#[cfg(test)]
1658mod resolution_stats_tests {
1659    use super::{ResolutionStats, type_candidates};
1660
1661    #[test]
1662    fn anchored_rate_is_absent_when_the_pass_measured_nothing() {
1663        assert_eq!(ResolutionStats::default().anchored_unresolved_rate(), None);
1664    }
1665
1666    #[test]
1667    fn anchored_rate_excludes_external_references() {
1668        let stats = ResolutionStats {
1669            scope: 4,
1670            import: 3,
1671            scip: 42,
1672            compiler_rescued_internal: 2,
1673            unresolved_internal: 1,
1674            unresolved_external: 90,
1675            ..ResolutionStats::default()
1676        };
1677
1678        assert_eq!(stats.anchored_unresolved_rate(), Some(0.1));
1679    }
1680
1681    #[test]
1682    fn written_type_unwraps_only_receiver_transparent_wrappers() {
1683        assert_eq!(type_candidates("&Dog"), ["Dog"]);
1684        assert_eq!(type_candidates("std::sync::Arc<dyn Harness>"), ["Harness"]);
1685        assert_eq!(type_candidates("Option<Dog>"), ["Option"]);
1686        assert_eq!(type_candidates("Result<Dog, Error>"), ["Result"]);
1687    }
1688}