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, LocalBinding, Node, NodeId, Reference, Relation, SymbolKind, TraitImpl,
11};
12use sinter_extract::{LanguageSpec, ModuleRoot, spec_for_path};
13
14pub struct Binding {
15    pub edge: Edge,
16    /// Index into the references slice passed to [`resolve`].
17    pub reference: usize,
18}
19
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
21pub struct ResolutionStats {
22    pub scope: usize,
23    pub import: usize,
24    pub scip: usize,
25    /// Evidence pointed into the corpus but binding failed (ambiguity,
26    /// member missing on a known module/type). The accuracy gauge.
27    pub unresolved_internal: usize,
28    /// No corpus-anchored evidence: external imports, builtins, and
29    /// value-receiver calls without type evidence. Dependency-index (SCIP)
30    /// territory, not a resolver defect.
31    pub unresolved_external: usize,
32    /// References bound by both internal evidence and SCIP, split by
33    /// whether the two agreed on the target — the measured trust level
34    /// of non-scip edges.
35    pub scip_agree: usize,
36    pub scip_disagree: usize,
37    /// Refs bound to synthesized dependency-surface nodes (D29). Counted
38    /// apart from `scip` and excluded from the cross-check and recall
39    /// denominators: internal evidence can never find a symbol with no
40    /// in-corpus definition, so mixing these in would fake a regression.
41    pub scip_external: usize,
42}
43
44impl ResolutionStats {
45    pub fn resolved(&self) -> usize {
46        self.scope + self.import + self.scip + self.scip_external
47    }
48
49    pub fn unresolved(&self) -> usize {
50        self.unresolved_internal + self.unresolved_external
51    }
52
53    pub fn unresolved_rate(&self) -> f64 {
54        let total = self.resolved() + self.unresolved();
55        if total == 0 {
56            0.0
57        } else {
58            self.unresolved() as f64 / total as f64
59        }
60    }
61
62    /// Internal-unresolved over corpus-anchored references — the number
63    /// that measures resolver accuracy rather than corpus openness.
64    /// Dep-surface binds are excluded from the denominator: they are not
65    /// corpus-anchored, and counting them would flatter the gauge.
66    pub fn internal_unresolved_rate(&self) -> f64 {
67        let total = self.scope + self.import + self.scip + self.unresolved_internal;
68        if total == 0 {
69            0.0
70        } else {
71            self.unresolved_internal as f64 / total as f64
72        }
73    }
74}
75
76/// Per-reference resolution verdict.
77enum Res {
78    Bound(Binding),
79    Internal,
80    External,
81}
82
83/// `{file}#{qualified}@{start}` -> qualified; plain file ids map to themselves.
84pub fn qualified_of(id: &str) -> &str {
85    match id.split_once('#') {
86        Some((_, rest)) => rest.rsplit_once('@').map_or(rest, |(q, _)| q),
87        None => id,
88    }
89}
90
91/// Kinds a "call" landing on means conversion/use, and namespace_pick
92/// prefers for Uses. Class is deliberately absent: instantiation really is
93/// a call (D14).
94fn is_type_kind(kind: SymbolKind) -> bool {
95    matches!(
96        kind,
97        SymbolKind::Struct
98            | SymbolKind::Enum
99            | SymbolKind::Interface
100            | SymbolKind::Trait
101            | SymbolKind::TypeAlias
102    )
103}
104
105/// Kinds that can own members for typed-local/receiver lookup — Class
106/// included here (a C++ local typed as a class binds its methods;
107/// fixture: cpp-header-impl).
108fn is_member_scope(kind: SymbolKind) -> bool {
109    is_type_kind(kind) || kind == SymbolKind::Class
110}
111
112fn is_callable(kind: SymbolKind) -> bool {
113    matches!(
114        kind,
115        SymbolKind::Function | SymbolKind::Method | SymbolKind::Macro | SymbolKind::Class
116    )
117}
118
119struct ModuleFiles<'a> {
120    key: Vec<String>,
121    files: Vec<&'a str>,
122}
123
124struct LocalRange<'a> {
125    start: u64,
126    scope_end: u64,
127    type_name: Option<&'a str>,
128}
129
130struct Import {
131    segments: Vec<String>,
132    /// Locally bound name: alias, or the last path segment.
133    binding: String,
134    /// Dot/star import: binds every top-level name of the module.
135    glob: bool,
136}
137
138struct FileDef<'a> {
139    node: &'a Node,
140    /// Qualified prefix ("Server" for Server::run; "" for top level).
141    prefix: String,
142    /// Every ancestor on the prefix is function-like, so the name is
143    /// lexically visible bare inside them (nested fns yes, methods no).
144    functionish: bool,
145}
146
147struct Index<'a> {
148    /// (file, plain name) -> defs with visibility info.
149    by_file_name: HashMap<(&'a str, &'a str), Vec<FileDef<'a>>>,
150    /// (file, qualified) -> def, receiver/type lookups.
151    by_file_qualified: HashMap<(&'a str, &'a str), &'a Node>,
152    /// exact file path -> file node (includes naming a literal repo file).
153    file_nodes: HashMap<&'a str, &'a Node>,
154    /// file -> its non-file defs, for fragment-slug lookup (file_refs).
155    defs_by_file: HashMap<&'a str, Vec<&'a Node>>,
156    /// name -> (absolute module segments, def).
157    by_name: HashMap<&'a str, Vec<(Vec<String>, &'a Node)>>,
158    /// last module segment -> (module segments, file node).
159    by_module_tail: HashMap<String, Vec<(Vec<String>, &'a Node)>>,
160    /// last module segment -> (module segments, files in it) — re-export
161    /// chain walking must never scan every module.
162    files_of_module: HashMap<String, Vec<ModuleFiles<'a>>>,
163    /// module segments -> top-level def name -> defs.
164    module_defs: HashMap<Vec<String>, HashMap<&'a str, Vec<&'a Node>>>,
165    /// file -> absolutized imports.
166    imports: HashMap<&'a str, Vec<Import>>,
167    /// (file, name) -> local bindings.
168    locals: HashMap<(&'a str, &'a str), Vec<LocalRange<'a>>>,
169    /// owner node id -> embedded type names.
170    embeds: HashMap<&'a str, Vec<&'a str>>,
171    /// Discovered package roots (manifest-declared name <-> directory).
172    roots: Vec<ModuleRoot>,
173}
174
175/// Module key of a file, manifest-aware: under a discovered package
176/// root, the key is rooted at the *declared package name* (with the
177/// language's self-alias, e.g. Rust's "crate", replaced by it) so that
178/// cross-package imports naming the package match. Outside any root the
179/// plain module_path applies — single-package repos are unchanged.
180fn key_of(spec: &LanguageSpec, roots: &[ModuleRoot], file: &str) -> Vec<String> {
181    let Some((manifest, root)) = spec.manifest.zip(root_of(spec, roots, file)) else {
182        return (spec.module_path)(file);
183    };
184    let rel = if root.dir.is_empty() {
185        file
186    } else {
187        &file[root.dir.len() + 1..]
188    };
189    let mut key = (spec.module_path)(rel);
190    match key.first() {
191        Some(head) if manifest.self_names.contains(&head.as_str()) => {
192            key[0] = root.name.clone();
193        }
194        _ => key.insert(0, root.name.clone()),
195    }
196    key
197}
198
199/// Deepest package root containing `file` for this language.
200fn root_of<'r>(spec: &LanguageSpec, roots: &'r [ModuleRoot], file: &str) -> Option<&'r ModuleRoot> {
201    roots
202        .iter()
203        .filter(|r| r.language == spec.name)
204        .filter(|r| r.dir.is_empty() || file.starts_with(&format!("{}/", r.dir)))
205        .max_by_key(|r| r.dir.len())
206}
207
208/// Rewrite a reference path's self-alias head ("crate::x") to the
209/// enclosing package's declared name, so it matches manifest-aware keys.
210fn expand(
211    spec: &LanguageSpec,
212    roots: &[ModuleRoot],
213    file: &str,
214    mut segments: Vec<String>,
215) -> Vec<String> {
216    if let Some(manifest) = spec.manifest
217        && let Some(head) = segments.first()
218        && manifest.self_names.contains(&head.as_str())
219        && let Some(root) = root_of(spec, roots, file)
220    {
221        segments[0] = root.name.clone();
222    }
223    segments
224}
225
226fn module_of(node: &Node, roots: &[ModuleRoot]) -> Vec<String> {
227    let mut module = spec_for_path(&node.file)
228        .map(|s| key_of(s, roots, &node.file))
229        .unwrap_or_default();
230    let qualified = qualified_of(node.id.as_str());
231    if let Some((prefix, _)) = qualified.rsplit_once("::") {
232        module.extend(prefix.split("::").map(str::to_string));
233    }
234    module
235}
236
237fn build_index<'a>(
238    nodes: &'a [Node],
239    all_imports: &'a [Reference],
240    locals: &'a [LocalBinding],
241    embeds: &'a [Embed],
242    roots: &[ModuleRoot],
243) -> Index<'a> {
244    let mut index = Index {
245        by_file_name: HashMap::new(),
246        by_file_qualified: HashMap::new(),
247        file_nodes: HashMap::new(),
248        defs_by_file: HashMap::new(),
249        by_name: HashMap::new(),
250        by_module_tail: HashMap::new(),
251        files_of_module: HashMap::new(),
252        module_defs: HashMap::new(),
253        imports: HashMap::new(),
254        locals: HashMap::new(),
255        embeds: HashMap::new(),
256        roots: roots.to_vec(),
257    };
258    // Pass 1: qualified -> kind per file, for ancestor-kind checks.
259    let mut kind_of: HashMap<(&str, &str), SymbolKind> = HashMap::new();
260    for node in nodes {
261        kind_of.insert(
262            (node.file.as_str(), qualified_of(node.id.as_str())),
263            node.kind,
264        );
265    }
266    for node in nodes {
267        let Some(spec) = spec_for_path(&node.file) else {
268            continue;
269        };
270        let file_module = key_of(spec, roots, &node.file);
271        if node.kind == SymbolKind::File {
272            index.file_nodes.insert(node.file.as_str(), node);
273            if let Some(tail) = file_module.last() {
274                index
275                    .by_module_tail
276                    .entry(tail.clone())
277                    .or_default()
278                    .push((file_module.clone(), node));
279            }
280            if let Some(tail) = file_module.last() {
281                let entries = index.files_of_module.entry(tail.clone()).or_default();
282                match entries.iter_mut().find(|m| m.key == file_module) {
283                    Some(m) => m.files.push(&node.file),
284                    None => entries.push(ModuleFiles {
285                        key: file_module.clone(),
286                        files: vec![&node.file],
287                    }),
288                }
289            }
290            continue;
291        }
292        let qualified = qualified_of(node.id.as_str());
293        let prefix = qualified.rsplit_once("::").map_or("", |(p, _)| p);
294        let functionish =
295            prefix
296                .split("::")
297                .filter(|s| !s.is_empty())
298                .try_fold(String::new(), |acc, seg| {
299                    let q = if acc.is_empty() {
300                        seg.to_string()
301                    } else {
302                        format!("{acc}::{seg}")
303                    };
304                    let kind = kind_of.get(&(node.file.as_str(), q.as_str()));
305                    match kind {
306                        Some(k) if is_callable(*k) && *k != SymbolKind::Class => Some(q),
307                        None => None, // impl/receiver scope: not lexically callable
308                        Some(_) => None,
309                    }
310                });
311        index
312            .by_file_qualified
313            .insert((node.file.as_str(), qualified), node);
314        index
315            .defs_by_file
316            .entry(node.file.as_str())
317            .or_default()
318            .push(node);
319        index
320            .by_file_name
321            .entry((node.file.as_str(), node.name.as_str()))
322            .or_default()
323            .push(FileDef {
324                node,
325                prefix: prefix.to_string(),
326                functionish: prefix.is_empty() || functionish.is_some(),
327            });
328        let mut module = file_module.clone();
329        if !prefix.is_empty() {
330            module.extend(prefix.split("::").map(str::to_string));
331        }
332        index
333            .by_name
334            .entry(node.name.as_str())
335            .or_default()
336            .push((module, node));
337        if prefix.is_empty() {
338            index
339                .module_defs
340                .entry(file_module)
341                .or_default()
342                .entry(node.name.as_str())
343                .or_default()
344                .push(node);
345        }
346    }
347    for r in all_imports {
348        let Some(spec) = spec_for_path(&r.file) else {
349            continue;
350        };
351        let glob = matches!(r.alias.as_deref(), Some("*") | Some("."));
352        let raw = strip_glob(&r.name);
353        let segments = expand(spec, roots, &r.file, (spec.absolutize)(raw, &r.file));
354        let binding = match (&r.alias, glob) {
355            (Some(alias), false) => alias.clone(),
356            _ => segments.last().cloned().unwrap_or_default(),
357        };
358        index
359            .imports
360            .entry(r.file.as_str())
361            .or_default()
362            .push(Import {
363                segments,
364                binding,
365                glob,
366            });
367    }
368    for l in locals {
369        index
370            .locals
371            .entry((l.file.as_str(), l.name.as_str()))
372            .or_default()
373            .push(LocalRange {
374                start: l.span.start,
375                scope_end: l.scope_end,
376                type_name: l.type_name.as_deref(),
377            });
378    }
379    for e in embeds {
380        index
381            .embeds
382            .entry(e.owner.as_str())
383            .or_default()
384            .push(&e.type_name);
385    }
386    index
387}
388
389fn strip_glob(name: &str) -> &str {
390    name.strip_suffix('*')
391        .map(|s| s.trim_end_matches(['.', ':', '/']))
392        .unwrap_or(name)
393}
394
395impl<'a> Index<'a> {
396    /// Local binding in scope at `at`, returning its declared type if any.
397    fn local_at(&self, file: &str, name: &str, at: u64) -> Option<Option<&'a str>> {
398        self.locals
399            .get(&(file, name))
400            .into_iter()
401            .flatten()
402            .filter(|l| l.start <= at && at < l.scope_end)
403            .map(|l| l.type_name)
404            .next_back()
405    }
406
407    /// A type definition visible from `file`: same file, then same module.
408    fn type_def(&self, file: &str, module: &[String], name: &str) -> Option<&'a Node> {
409        let same_file: Vec<&Node> = self
410            .by_file_name
411            .get(&(file, name))
412            .into_iter()
413            .flatten()
414            .filter(|d| is_member_scope(d.node.kind))
415            .map(|d| d.node)
416            .collect();
417        if let [node] = same_file.as_slice() {
418            return Some(node);
419        }
420        let in_module: Vec<&Node> = self
421            .module_defs
422            .get(module)
423            .and_then(|m| m.get(name))
424            .into_iter()
425            .flatten()
426            .filter(|n| is_member_scope(n.kind))
427            .copied()
428            .collect();
429        match in_module.as_slice() {
430            [node] => Some(node),
431            _ => None,
432        }
433    }
434
435    /// Member `name` of type `ty`, following embedded types.
436    fn member_of(&self, ty: &'a Node, name: &str, depth: usize) -> Option<&'a Node> {
437        if depth == 0 {
438            return None;
439        }
440        let mut module = module_of(ty, &self.roots);
441        module.extend(
442            qualified_of(ty.id.as_str())
443                .rsplit("::")
444                .next()
445                .map(str::to_string),
446        );
447        let direct: Vec<&Node> = self
448            .by_name
449            .get(name)
450            .into_iter()
451            .flatten()
452            .filter(|(m, _)| *m == module)
453            .map(|(_, n)| *n)
454            .collect();
455        if let [node] = direct.as_slice() {
456            return Some(node);
457        }
458        // Header/impl pairs declare and define the same member in one
459        // module: the declaration inside the type's own file IS the
460        // entity (fixture: cpp-header-impl).
461        let in_type_file: Vec<&Node> = direct
462            .iter()
463            .filter(|n| n.file == ty.file)
464            .copied()
465            .collect();
466        if let [node] = in_type_file.as_slice() {
467            return Some(node);
468        }
469        let spec = spec_for_path(&ty.file)?;
470        let file_module = key_of(spec, &self.roots, &ty.file);
471        for embedded in self.embeds.get(ty.id.as_str()).into_iter().flatten() {
472            if let Some(embedded_ty) = self.type_def(&ty.file, &file_module, embedded)
473                && let Some(node) = self.member_of(embedded_ty, name, depth - 1)
474            {
475                return Some(node);
476            }
477        }
478        None
479    }
480
481    /// Does this path point at anything in the corpus (module suffix
482    /// match or a same-named module part), regardless of unique binding?
483    fn anchored(&self, segments: &[String]) -> bool {
484        let module_hit = |segs: &[String]| {
485            segs.last().is_some_and(|tail| {
486                self.files_of_module
487                    .get(tail.as_str())
488                    .into_iter()
489                    .flatten()
490                    .any(|m| suffix_len(&m.key, segs).is_some())
491                    || self
492                        .by_module_tail
493                        .get(tail.as_str())
494                        .into_iter()
495                        .flatten()
496                        .any(|(key, _)| suffix_len(key, segs).is_some())
497            })
498        };
499        if module_hit(segments) {
500            return true;
501        }
502        match segments.split_last() {
503            Some((_, module)) if !module.is_empty() => module_hit(module),
504            _ => false,
505        }
506    }
507
508    /// File node for an import path, matching either containment
509    /// direction: Go-style (long import, short module key) or
510    /// include-root style (protoc, C headers) where the import resolves
511    /// against roots the graph can't see and the file's repo path ends
512    /// with it. Import-evidence sites only — a bare qualified reference
513    /// must never bind this loosely. Unique or nothing.
514    fn import_file(&self, segments: &[String]) -> Option<&'a Node> {
515        unique_best(
516            self.by_module_tail
517                .get(segments.last()?.as_str())
518                .into_iter()
519                .flatten()
520                .filter_map(|(key, node)| {
521                    let len = suffix_len(key, segments).or_else(|| suffix_len(segments, key))?;
522                    Some((len, *node))
523                }),
524        )
525    }
526
527    /// Resolve absolute segments to a definition or module file node,
528    /// following re-export chains up to a small depth.
529    fn resolve_path(&self, segments: &[String], depth: usize) -> Option<&'a Node> {
530        self.resolve_path_defs(segments, depth).or_else(|| {
531            // Module/package: bind to its file node.
532            // ponytail: single-file packages only; multi-file packages stay
533            // unresolved here — bind-to-all-files when a consumer needs it.
534            let files = self
535                .by_module_tail
536                .get(segments.last()?.as_str())
537                .into_iter()
538                .flatten()
539                .filter_map(|(key, node)| Some((suffix_len(key, segments)?, *node)));
540            unique_best(files)
541        })
542    }
543
544    /// Like [`resolve_path`] but definitions only — a qualified call or
545    /// use must never bind to an unrelated module *file* through the
546    /// loose tail fallback (a Rust `hooks::install()` once bound to a
547    /// bash `install.sh` this way); the file fallback is import-context
548    /// evidence.
549    fn resolve_path_defs(&self, segments: &[String], depth: usize) -> Option<&'a Node> {
550        if segments.is_empty() || depth == 0 {
551            return None;
552        }
553        if let Some((name, module)) = segments.split_last() {
554            let defs = self
555                .by_name
556                .get(name.as_str())
557                .into_iter()
558                .flatten()
559                .filter_map(|(key, node)| Some((suffix_len(key, module)?, *node)));
560            if let Some(node) = unique_best(defs) {
561                return Some(node);
562            }
563            // Re-export chain: the module part names files that re-export
564            // this name — follow their imports.
565            if !module.is_empty() {
566                let mut chained: Vec<&Node> = Vec::new();
567                let tail = module.last().map(String::as_str).unwrap_or("");
568                for m in self.files_of_module.get(tail).into_iter().flatten() {
569                    if suffix_len(&m.key, module).is_none() {
570                        continue;
571                    }
572                    for file in &m.files {
573                        for import in self.imports.get(*file).into_iter().flatten() {
574                            if import.binding == *name && !import.glob {
575                                chained.extend(self.resolve_path(&import.segments, depth - 1));
576                            } else if import.glob {
577                                let mut deeper = import.segments.clone();
578                                deeper.push(name.clone());
579                                chained.extend(self.resolve_path(&deeper, depth - 1));
580                            }
581                        }
582                    }
583                }
584                chained.sort_by_key(|n| n.id.as_str().to_string());
585                chained.dedup_by_key(|n| n.id.as_str().to_string());
586                if let [node] = chained.as_slice() {
587                    return Some(node);
588                }
589            }
590        }
591        None
592    }
593}
594
595/// Pick among same-name candidates: a call prefers callables, a use prefers
596/// types (value vs type namespace). Applied only on ambiguity.
597fn namespace_pick(candidates: Vec<&Node>, relation: Relation) -> Option<&Node> {
598    match candidates.as_slice() {
599        [node] => Some(node),
600        [] => None,
601        _ => {
602            let preferred: Vec<&Node> = candidates
603                .iter()
604                .filter(|n| match relation {
605                    Relation::Calls => is_callable(n.kind),
606                    Relation::Uses => is_type_kind(n.kind),
607                    _ => true,
608                })
609                .copied()
610                .collect();
611            match preferred.as_slice() {
612                [node] => Some(node),
613                _ => None,
614            }
615        }
616    }
617}
618
619pub fn resolve(
620    nodes: &[Node],
621    references: &[Reference],
622    locals: &[LocalBinding],
623    all_imports: &[Reference],
624    embeds: &[Embed],
625    roots: &[ModuleRoot],
626) -> (Vec<Binding>, ResolutionStats, Vec<usize>) {
627    let t = std::time::Instant::now();
628    let index = build_index(nodes, all_imports, locals, embeds, roots);
629    if std::env::var_os("SINTER_TIMING").is_some() {
630        eprintln!("index build: {:?}", t.elapsed());
631    }
632    use rayon::prelude::*;
633    let results: Vec<Res> = references
634        .par_iter()
635        .enumerate()
636        .map(|(i, r)| {
637            let Some(spec) = spec_for_path(&r.file) else {
638                return Res::External;
639            };
640            let src = r
641                .enclosing
642                .clone()
643                .unwrap_or_else(|| NodeId::new(r.file.clone()));
644            let file_module = key_of(spec, &index.roots, &r.file);
645            let imports = index.imports.get(r.file.as_str());
646            let (target, evidence, internal) = resolve_one(&index, spec, r, &file_module, imports);
647            match target {
648                Some(node) if node.id != src => {
649                    // A "call" landing on a type is a conversion or
650                    // instantiation of a non-callable kind: it is a use.
651                    let relation = if r.relation == Relation::Calls && is_type_kind(node.kind) {
652                        Relation::Uses
653                    } else {
654                        r.relation
655                    };
656                    Res::Bound(Binding {
657                        edge: Edge {
658                            src,
659                            dst: node.id.clone(),
660                            relation,
661                            evidence,
662                            confidence: evidence.confidence(),
663                        },
664                        reference: i,
665                    })
666                }
667                _ if internal => Res::Internal,
668                _ => Res::External,
669            }
670        })
671        .collect();
672    let mut bindings = Vec::new();
673    let mut stats = ResolutionStats::default();
674    let mut internal_indices = Vec::new();
675    for (i, result) in results.into_iter().enumerate() {
676        match result {
677            Res::Bound(binding) => {
678                match binding.edge.evidence {
679                    Evidence::Scope => stats.scope += 1,
680                    _ => stats.import += 1,
681                }
682                bindings.push(binding);
683            }
684            Res::Internal => {
685                stats.unresolved_internal += 1;
686                internal_indices.push(i);
687            }
688            Res::External => stats.unresolved_external += 1,
689        }
690    }
691    (bindings, stats, internal_indices)
692}
693
694fn resolve_one<'a>(
695    index: &Index<'a>,
696    spec: &sinter_extract::LanguageSpec,
697    r: &Reference,
698    file_module: &[String],
699    imports: Option<&Vec<Import>>,
700) -> (Option<&'a Node>, Evidence, bool) {
701    if r.relation == Relation::Imports {
702        let glob = matches!(r.alias.as_deref(), Some("*") | Some("."));
703        let raw = strip_glob(&r.name);
704        // An import naming a literal repo file binds it exactly — this is
705        // how `#include "player/character.h"` stays unambiguous even though
706        // header and impl share one module (fixture: cpp-header-impl).
707        if let Some(node) = index
708            .file_nodes
709            .get(raw.trim().trim_matches(['<', '>', '"']))
710        {
711            return (Some(node), Evidence::Import, true);
712        }
713        let segments = expand(spec, &index.roots, &r.file, (spec.absolutize)(raw, &r.file));
714        let target = if glob {
715            index.import_file(&segments)
716        } else {
717            index.resolve_path(&segments, 4)
718        };
719        let internal = target.is_some() || index.anchored(&segments);
720        return (target, Evidence::Import, internal);
721    }
722
723    if let Some(path) = &r.path {
724        // Document-path languages (spec.file_refs): the path names a
725        // corpus file, never a symbol — dedicated tier, no fallthrough.
726        if spec.file_refs {
727            return resolve_file_ref(index, spec, r, path);
728        }
729        // Qualified reference: receiver, typed local, shadow, absolute
730        // path, then imports — strongest local knowledge first.
731        let segments = expand(
732            spec,
733            &index.roots,
734            &r.file,
735            (spec.absolutize)(path, &r.file),
736        );
737        let prefix = segments
738            .len()
739            .checked_sub(2)
740            .and_then(|p| segments.get(p))
741            .cloned();
742        let Some(prefix) = prefix else {
743            return (None, Evidence::Import, false);
744        };
745        if spec.receivers.contains(&prefix.as_str())
746            && let Some(enclosing) = &r.enclosing
747            && let Some((type_prefix, _)) = qualified_of(enclosing.as_str()).rsplit_once("::")
748            && let Some(ty) = index.by_file_qualified.get(&(r.file.as_str(), type_prefix))
749        {
750            // Receiver type is in the corpus: any miss is internal.
751            return (index.member_of(ty, &r.name, 4), Evidence::Scope, true);
752        }
753        match index.local_at(&r.file, &prefix, r.span.start) {
754            Some(Some(type_name)) => {
755                let ty = index.type_def(&r.file, file_module, type_name);
756                let target = ty.and_then(|ty| index.member_of(ty, &r.name, 4));
757                // Known corpus type but missing member -> internal.
758                return (target, Evidence::Scope, ty.is_some());
759            }
760            Some(None) => return (None, Evidence::Scope, false), // shadowed: correctly no edge
761            None => {}
762        }
763        // Same-scope type qualifier (Counter::new in the type's own file).
764        if let Some(ty) = index.type_def(&r.file, file_module, &prefix)
765            && let Some(node) = index.member_of(ty, &r.name, 4)
766        {
767            return (Some(node), Evidence::Scope, true);
768        }
769        if let Some(node) = index.resolve_path_defs(&segments, 4) {
770            return (Some(node), Evidence::Import, true);
771        }
772        // Associated item through a path: the second-to-last segment is a
773        // *type*, not a module (`some_crate::Config::new`,
774        // `ns::Class::method`). Resolve the prefix as a path — re-export
775        // chains included — then look the leaf up as a member. Path
776        // shape, not language shape: active for every language.
777        if let Some((leaf, type_path)) = segments.split_last()
778            && type_path.len() >= 2
779            && let Some(ty) = index.resolve_path_defs(type_path, 4)
780            && let Some(node) = index.member_of(ty, leaf, 4)
781        {
782            return (Some(node), Evidence::Import, true);
783        }
784        let matching: Vec<&Import> = imports
785            .into_iter()
786            .flatten()
787            .filter(|imp| !imp.glob && imp.binding == prefix)
788            .collect();
789        let candidates: Vec<&Node> = matching
790            .iter()
791            .filter_map(|imp| {
792                let mut full = imp.segments.clone();
793                full.push(r.name.clone());
794                index.resolve_path(&full, 4)
795            })
796            .collect();
797        let internal = candidates.len() > 1
798            || index.anchored(&segments)
799            || matching.iter().any(|imp| index.anchored(&imp.segments));
800        return match candidates.as_slice() {
801            [node] => (Some(node), Evidence::Import, true),
802            _ => (None, Evidence::Import, internal),
803        };
804    }
805
806    // Bare name.
807    if index.local_at(&r.file, &r.name, r.span.start).is_some() {
808        return (None, Evidence::Scope, false); // shadowed: correctly no edge
809    }
810    let enclosing_q = r
811        .enclosing
812        .as_ref()
813        .map(|e| qualified_of(e.as_str()))
814        .unwrap_or("");
815    let visible: Vec<&Node> = index
816        .by_file_name
817        .get(&(r.file.as_str(), r.name.as_str()))
818        .into_iter()
819        .flatten()
820        .filter(|d| {
821            d.prefix.is_empty()
822                || (d.functionish
823                    && (enclosing_q == d.prefix
824                        || enclosing_q.starts_with(&format!("{}::", d.prefix))))
825        })
826        .map(|d| d.node)
827        .collect();
828    if !visible.is_empty() {
829        // Candidates exist in scope: a miss here is ambiguity — internal.
830        return (namespace_pick(visible, r.relation), Evidence::Scope, true);
831    }
832    if let Some(defs) = index
833        .module_defs
834        .get(file_module)
835        .and_then(|m| m.get(r.name.as_str()))
836    {
837        return (
838            namespace_pick(defs.clone(), r.relation),
839            Evidence::Scope,
840            true,
841        );
842    }
843    let named: Vec<&Node> = imports
844        .into_iter()
845        .flatten()
846        .filter(|imp| !imp.glob && imp.binding == r.name)
847        .filter_map(|imp| index.resolve_path(&imp.segments, 4))
848        .collect();
849    let (target, internal) = match named.as_slice() {
850        [node] => (Some(*node), true),
851        [] => {
852            let globbed: Vec<&Node> = imports
853                .into_iter()
854                .flatten()
855                .filter(|imp| imp.glob)
856                .filter_map(|imp| {
857                    let mut full = imp.segments.clone();
858                    full.push(r.name.clone());
859                    index.resolve_path(&full, 4).or_else(|| {
860                        // Include-root import: bind via the imported
861                        // file's own top-level definitions.
862                        let file = index.import_file(&imp.segments)?;
863                        index
864                            .by_file_name
865                            .get(&(file.file.as_str(), r.name.as_str()))
866                            .into_iter()
867                            .flatten()
868                            .find(|d| d.prefix.is_empty())
869                            .map(|d| d.node)
870                    })
871                })
872                .collect();
873            let name_imports_anchored = imports
874                .into_iter()
875                .flatten()
876                .filter(|imp| !imp.glob && imp.binding == r.name)
877                .any(|imp| index.anchored(&imp.segments));
878            match globbed.as_slice() {
879                [node] => (Some(*node), true),
880                [] => (None, name_imports_anchored),
881                _ => (None, true), // glob ambiguity across corpus modules
882            }
883        }
884        _ => (None, true), // ambiguous named imports
885    };
886    (target, Evidence::Import, internal)
887}
888
889/// Document-path reference (spec.file_refs, e.g. a markdown link): the
890/// path resolves to a corpus file — the same exact-file evidence imports
891/// carry — with the language's extensions optional and `#fragment`
892/// binding the target file's unique def whose name slugifies to the
893/// fragment (`#quality-gate` -> the "Quality Gate" section). A path that
894/// names no corpus file is a dead or external link and stays unresolved:
895/// evidence or nothing, never a guess.
896fn resolve_file_ref<'a>(
897    index: &Index<'a>,
898    spec: &LanguageSpec,
899    r: &Reference,
900    path: &str,
901) -> (Option<&'a Node>, Evidence, bool) {
902    let (head, frag) = match path.split_once('#') {
903        Some((h, f)) => (h, Some(f)),
904        None => (path, None),
905    };
906    let file = if head.is_empty() {
907        // `#fragment` alone: the linking file itself.
908        index.file_nodes.get(r.file.as_str()).copied()
909    } else {
910        let joined = (spec.absolutize)(head, &r.file).join("/");
911        index.file_nodes.get(joined.as_str()).copied().or_else(|| {
912            spec.extensions.iter().find_map(|ext| {
913                index
914                    .file_nodes
915                    .get(format!("{joined}.{ext}").as_str())
916                    .copied()
917            })
918        })
919    };
920    match (file, frag) {
921        (Some(file), None) => (Some(file), Evidence::Import, true),
922        (Some(file), Some(frag)) => {
923            let matching: Vec<&Node> = index
924                .defs_by_file
925                .get(file.file.as_str())
926                .into_iter()
927                .flatten()
928                .filter(|n| slugify(&n.name) == frag)
929                .copied()
930                .collect();
931            // The file is corpus evidence: a fragment miss (or a
932            // duplicate slug) is internal, and unique-or-nothing holds.
933            match matching.as_slice() {
934                [node] => (Some(node), Evidence::Import, true),
935                _ => (None, Evidence::Import, true),
936            }
937        }
938        (None, _) => (None, Evidence::Import, false),
939    }
940}
941
942/// GitHub-style heading slug: lowercase, spaces become dashes, `-`/`_`
943/// survive, other punctuation drops.
944fn slugify(name: &str) -> String {
945    name.chars()
946        .filter_map(|c| match c {
947            ' ' => Some('-'),
948            '-' | '_' => Some(c),
949            c if c.is_alphanumeric() => Some(c.to_ascii_lowercase()),
950            _ => None,
951        })
952        .collect()
953}
954
955/// Dynamic-dispatch fan-out edges: for every impl block naming a trait the
956/// corpus defines, `trait_method -> impl_method` (Calls, Dynamic) for each
957/// method the impl defines under a same-named trait method. Conservative
958/// over-approximation — every impl is assumed reachable through the trait —
959/// which is exactly why the edges carry the distinct Dynamic evidence.
960/// Pairing rule: the impl block names the trait (same file/module, or a
961/// named import) and the method names match.
962pub fn dynamic_edges(
963    nodes: &[Node],
964    trait_impls: &[TraitImpl],
965    all_imports: &[Reference],
966    roots: &[ModuleRoot],
967) -> Vec<Edge> {
968    if trait_impls.is_empty() {
969        return Vec::new();
970    }
971    let index = build_index(nodes, all_imports, &[], &[], roots);
972    let mut by_file: HashMap<&str, Vec<&Node>> = HashMap::new();
973    for n in nodes {
974        if is_callable(n.kind) {
975            by_file.entry(n.file.as_str()).or_default().push(n);
976        }
977    }
978    // Class included: C# captures base classes as @trait because its
979    // virtual dispatch flows through them; Rust/Java only ever emit
980    // @trait on real traits/interfaces, so they are unaffected.
981    let is_trait = |n: &Node| {
982        matches!(
983            n.kind,
984            SymbolKind::Trait | SymbolKind::Interface | SymbolKind::Class
985        )
986    };
987    let mut edges = Vec::new();
988    for ti in trait_impls {
989        let Some(spec) = spec_for_path(&ti.file) else {
990            continue;
991        };
992        let file_module = key_of(spec, roots, &ti.file);
993        let trait_node = index
994            .type_def(&ti.file, &file_module, &ti.trait_name)
995            .filter(|n| is_trait(n))
996            .or_else(|| {
997                // Trait bound through a named import; unique or nothing.
998                let named: Vec<&Node> = index
999                    .imports
1000                    .get(ti.file.as_str())
1001                    .into_iter()
1002                    .flatten()
1003                    .filter(|imp| !imp.glob && imp.binding == ti.trait_name)
1004                    .filter_map(|imp| index.resolve_path_defs(&imp.segments, 4))
1005                    .filter(|n| is_trait(n))
1006                    .collect();
1007                match named.as_slice() {
1008                    [node] => Some(node),
1009                    _ => None,
1010                }
1011            });
1012        let Some(trait_node) = trait_node else {
1013            continue; // external trait: nothing in the corpus to fan into
1014        };
1015        for method in by_file.get(ti.file.as_str()).into_iter().flatten() {
1016            if !(ti.span.start <= method.span.start && method.span.end <= ti.span.end) {
1017                continue;
1018            }
1019            if let Some(trait_method) = index.member_of(trait_node, &method.name, 1)
1020                && trait_method.id != method.id
1021            {
1022                edges.push(Edge {
1023                    src: trait_method.id.clone(),
1024                    dst: method.id.clone(),
1025                    relation: Relation::Calls,
1026                    evidence: Evidence::Dynamic,
1027                    confidence: Evidence::Dynamic.confidence(),
1028                });
1029            }
1030        }
1031    }
1032    edges.sort();
1033    edges.dedup();
1034    edges
1035}
1036
1037/// Resolve references against FOREIGN definitions using import evidence
1038/// only — the cross-repo boundary pass. Same-file/module/receiver/local
1039/// tiers are intra-repo by definition and deliberately excluded, which
1040/// also prevents false bindings between identically-named files in
1041/// different members. `refs` and `owner_imports` come from one member;
1042/// `foreign_nodes` from the others.
1043pub fn resolve_boundary(
1044    foreign_nodes: &[Node],
1045    references: &[Reference],
1046    owner_imports: &[Reference],
1047) -> Vec<Binding> {
1048    let index = build_index(foreign_nodes, owner_imports, &[], &[], &[]);
1049    let mut bindings = Vec::new();
1050    for (i, r) in references.iter().enumerate() {
1051        let Some(spec) = spec_for_path(&r.file) else {
1052            continue;
1053        };
1054        let src = r
1055            .enclosing
1056            .clone()
1057            .unwrap_or_else(|| NodeId::new(r.file.clone()));
1058        let imports = index.imports.get(r.file.as_str());
1059        let target = if r.relation == Relation::Imports {
1060            let glob = matches!(r.alias.as_deref(), Some("*") | Some("."));
1061            let segments = (spec.absolutize)(strip_glob(&r.name), &r.file);
1062            if glob {
1063                index.import_file(&segments)
1064            } else {
1065                index.resolve_path(&segments, 4)
1066            }
1067        } else if let Some(path) = &r.path {
1068            let segments = (spec.absolutize)(path, &r.file);
1069            let direct = index.resolve_path(&segments, 4);
1070            direct.or_else(|| {
1071                let prefix = segments
1072                    .len()
1073                    .checked_sub(2)
1074                    .and_then(|p| segments.get(p))?;
1075                let candidates: Vec<&Node> = imports
1076                    .into_iter()
1077                    .flatten()
1078                    .filter(|imp| !imp.glob && imp.binding == *prefix)
1079                    .filter_map(|imp| {
1080                        let mut full = imp.segments.clone();
1081                        full.push(r.name.clone());
1082                        index.resolve_path(&full, 4)
1083                    })
1084                    .collect();
1085                match candidates.as_slice() {
1086                    [node] => Some(node),
1087                    _ => None,
1088                }
1089            })
1090        } else {
1091            // Bare name: only through this member's own imports.
1092            let named: Vec<&Node> = imports
1093                .into_iter()
1094                .flatten()
1095                .filter(|imp| !imp.glob && imp.binding == r.name)
1096                .filter_map(|imp| index.resolve_path(&imp.segments, 4))
1097                .collect();
1098            match named.as_slice() {
1099                [node] => Some(*node),
1100                _ => None,
1101            }
1102        };
1103        if let Some(node) = target
1104            && node.id != src
1105        {
1106            let relation = if r.relation == Relation::Calls && is_type_kind(node.kind) {
1107                Relation::Uses
1108            } else {
1109                r.relation
1110            };
1111            bindings.push(Binding {
1112                edge: Edge {
1113                    src,
1114                    dst: node.id.clone(),
1115                    relation,
1116                    evidence: Evidence::Import,
1117                    confidence: Evidence::Import.confidence(),
1118                },
1119                reference: i,
1120            });
1121        }
1122    }
1123    bindings
1124}
1125
1126/// Segment count of `key` if it is a non-empty suffix of `path`.
1127fn suffix_len(key: &[String], path: &[String]) -> Option<usize> {
1128    (!key.is_empty() && path.len() >= key.len() && path[path.len() - key.len()..] == key[..])
1129        .then_some(key.len())
1130}
1131
1132/// Of the longest-key candidates, the single node — or None on ambiguity.
1133fn unique_best<'a>(candidates: impl Iterator<Item = (usize, &'a Node)>) -> Option<&'a Node> {
1134    let mut best: Option<(usize, Vec<&Node>)> = None;
1135    for (len, node) in candidates {
1136        match &mut best {
1137            Some((best_len, nodes)) if len == *best_len => nodes.push(node),
1138            Some((best_len, nodes)) if len > *best_len => {
1139                *best_len = len;
1140                nodes.clear();
1141                nodes.push(node);
1142            }
1143            None => best = Some((len, vec![node])),
1144            _ => {}
1145        }
1146    }
1147    match best {
1148        Some((_, nodes)) if nodes.len() == 1 => Some(nodes[0]),
1149        _ => None,
1150    }
1151}