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