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