Skip to main content

devscout_rs/
resolve.rs

1// The resolution ladder, including ambiguous marking: `build_def_index`,
2// `resolve_ref`, `collect_global_usings`, `capped_candidates`, `resolve_graph`.
3// Pure: no file I/O, no tree-sitter -- the only I/O this module performs is the
4// single `git rev-parse HEAD` shell-out inside `resolve_graph`, delegated to
5// `manifest::git_head`. Artifact load/save and the fragments cache live in
6// `graph.rs`.
7//
8// Ladder rules (see `resolve_ref`'s doc comment for the exact order):
9//   0. Type ALIASES (`using Foo = Some.Ns.Bar;` and `global` counterpart)
10//      short-circuit before the ladder for bare (non-dotted) references --
11//      never ambiguous, never falls through.
12//   1. Exact qualified name, tried at every ENCLOSING namespace prefix,
13//      innermost first, only for dotted references.
14//   2. File's usings (local ∪ every `global using`) + simple name, each
15//      using name itself tried at every enclosing-namespace prefix.
16//   3. The reference site's namespace and every ancestor of it, innermost
17//      first (the ancestor-namespace rule -- a walk, like step 1).
18//   4. Globally unique simple name.
19//   A step that finds exactly one candidate resolves; two or more STOPS
20//   there as ambiguous, never falling through looking for a tiebreaker.
21//
22// The enum-member asymmetry (the single most load-bearing invariant here):
23// enum members ARE keyed in `qualified_name_to_def` (reachable by exact id,
24// e.g. for uses-member resolution) but are EXCLUDED from `simple_name_to_defs`
25// (the pool step 2/4's bare-name lookups draw from) -- see `build_def_index`.
26// Losing this exclusion doesn't change any TYPE resolution that was already
27// unambiguous via using/namespace/alias, but it does turn every type whose
28// simple name collides with some unrelated enum's member name into a false
29// ambiguous (or worse, a step-4 resolution picking the wrong one) purely
30// because that enum happens to exist somewhere in the same build. The test
31// `enum_member_does_not_collide_with_a_same_named_class_via_global_uniqueness`
32// below is a regression trap for exactly this: it fails loudly (ambiguous where
33// it should resolve cleanly) if the exclusion is ever dropped.
34
35use std::collections::{HashMap, HashSet};
36use std::path::Path;
37
38use crate::graph::{
39    AlsoIn, Candidate, Def, Edge, EdgesByKind, FragExtensionMethod, FragFact, FragRef, FragUsing,
40    Fragment, Graph, GraphName, OrderedMap, Percent1, Stats,
41};
42use crate::manifest;
43
44const AMBIGUOUS_CAP: usize = 5;
45
46// The two caps the scored heuristic tier lives inside. The uniqueness cap is a
47// REFUSAL threshold: a member name carried by more than this many defs
48// graph-wide is too common to guess from, so the tier emits nothing at all
49// rather than a wide fan of maybes. The emit cap bounds how many of the
50// surviving candidates a single ref may name.
51const SCORED_UNIQUENESS_CAP: usize = 3;
52const SCORED_EMIT_CAP: usize = 3;
53
54// ---------------------------------------------------------------------------
55// Def index.
56// ---------------------------------------------------------------------------
57
58/// `Vec<Def>` in first-insertion order (partial-class duplicates land in
59/// `also_in`, never as a second Vec entry) plus two lookup indexes over it:
60/// `qualified_name_to_def` (id -> index, every def incl. enum members) and
61/// `simple_name_to_defs` (bare name -> indexes, EVERY def EXCEPT enum members
62/// -- the asymmetry described in the module header).
63pub struct DefIndex {
64    /// The defs value.
65    pub defs: Vec<Def>,
66    /// Parallel to `defs` (same indexes), NOT fields on `Def`: a def's
67    /// property/field lists are RESOLUTION inputs only. No graph.json reader
68    /// consumes them (graph-query walks EDGES), so serializing them would
69    /// multiply the def table's size on disk and in every `scout refs`/`scout
70    /// impact` read for nothing. Keeping them off `Def` keeps the on-disk def
71    /// rows to exactly the serialized fields.
72    pub member_lists: Vec<MemberLists>,
73    /// The qualified name to def value.
74    pub qualified_name_to_def: HashMap<String, usize>,
75    /// The qualified name and arity to def value.
76    pub qualified_name_and_arity_to_def: HashMap<(String, usize), usize>,
77    /// The simple name to defs value.
78    pub simple_name_to_defs: HashMap<String, Vec<usize>>,
79    /// The extension tier's own lookup: "<method_name> <this_type>" (one ASCII
80    /// space) -> every candidate declaring that PAIR, as (def index, entry).
81    ///
82    /// The key was the (name, this_type, arity) TRIPLE until the range
83    /// amendment. Arity moved out of the key and into a per-entry range filter
84    /// because an exact arity is not the language's rule:
85    /// `Send(this T t, X a, Y b = null)` accepts two OR three arguments, and
86    /// keying on 3 alone made a call with two arguments miss it -- which in turn
87    /// made a DIFFERENT class's exact-arity-2 `Send` look like the only
88    /// candidate and win an edge it had no right to -- the false-uniqueness
89    /// shape.
90    ///
91    /// Build order is def order -- files in fragment-map order, defs in source
92    /// order, extension entries in declaration order -- so a bucket holding two
93    /// candidates holds them in the same order on every run. The tier refuses on
94    /// a 2-candidate bucket anyway; the order is pinned because it is what a
95    /// later scored tier would read.
96    pub extension_index: HashMap<String, Vec<ExtCandidate>>,
97    /// Member name -> every def that vouches for a member of that name, the
98    /// pool the scored tier's UNIQUENESS FALLBACK draws from when a ref's
99    /// qualifier resolves to nothing at all (`x.Tally()` where the extractor
100    /// could vouch for no type for `x`). Built AFTER the main def loop, not
101    /// during it, so a partial class contributes its accumulated member set once
102    /// instead of appearing in a bucket per declaring file.
103    ///
104    /// Insertion order is def order (fragment order, then source order), and
105    /// within one def the names are collected methods -> properties -> fields ->
106    /// extension-method names, first insertion winning. This order decides which
107    /// candidates a `<= SCORED_UNIQUENESS_CAP` pool holds.
108    pub member_name_to_defs: HashMap<String, Vec<usize>>,
109}
110
111/// One bucket slot: which def declares the entry, and the entry itself (the
112/// range + generic-argument facts the tier filters on).
113pub struct ExtCandidate {
114    /// The def idx value.
115    pub def_idx: usize,
116    /// The entry value.
117    pub entry: FragExtensionMethod,
118}
119
120/// The non-method halves of a def's member surface. `methods` stays on `Def`
121/// (it IS serialized).
122#[derive(Default)]
123pub struct MemberLists {
124    /// The properties value.
125    pub properties: Vec<String>,
126    /// The fields value.
127    pub fields: Vec<String>,
128    /// Kept here for the same reason as `properties`/`fields`: a resolution
129    /// input, never a serialized def field. Holds the (name, this_type,
130    /// arity_min, arity_max) QUADRUPLES this def has already contributed, so a
131    /// partial class re-declaring one of its own entries in a second file can
132    /// never push the def into its own extension bucket twice and make itself
133    /// look like two candidates.
134    pub extension_methods: Vec<(String, String, usize, i64)>,
135    /// DIRECT base-type names, unioned across a partial class's declarations,
136    /// resolved LAZILY by the veto walk.
137    pub bases: Vec<String>,
138    /// The declaring type's own type-parameter names (empty for a non-generic
139    /// declaration). A resolution input, like `bases`: the ctor-DI resolver's
140    /// "is this def itself an open-generic implementation" signal. First
141    /// declaration wins, like `namespace`/`kind` -- not unioned across a partial
142    /// class the way `bases` is.
143    pub type_params: Vec<String>,
144    /// Per base name that carried a type-argument list, that list's generic-arg
145    /// descriptors relative to `type_params`. First declaration wins, same as
146    /// `type_params`.
147    pub base_generic_args: Vec<(String, Vec<String>)>,
148    /// Method name -> declared return type NAME, the fact that turns a
149    /// `var x = Q.M(...)` local into an ordinary receiver. A partial class's
150    /// later parts contribute the names the first part did not answer; a name it
151    /// already answered keeps its answer.
152    pub method_returns: OrderedMap<String>,
153    /// Property name -> declared type fact, the second half of a property hop.
154    /// Merged across a partial class exactly like `method_returns`.
155    pub property_types: OrderedMap<FragFact>,
156}
157
158fn build_def_index(fragments_by_file: &[(String, Fragment)]) -> DefIndex {
159    let mut defs: Vec<Def> = Vec::new();
160    let mut member_lists: Vec<MemberLists> = Vec::new();
161    let mut qualified_name_to_def: HashMap<String, usize> = HashMap::new();
162    let mut qualified_name_and_arity_to_def: HashMap<(String, usize), usize> = HashMap::new();
163    let mut simple_name_to_defs: HashMap<String, Vec<usize>> = HashMap::new();
164    let mut extension_index: HashMap<String, Vec<ExtCandidate>> = HashMap::new();
165
166    // Dedupe on the def's OWN quadruple list FIRST, then push into the bucket --
167    // the guard is what keeps one def out of the same bucket twice.
168    fn add_extension_method(
169        member_lists: &mut [MemberLists],
170        extension_index: &mut HashMap<String, Vec<ExtCandidate>>,
171        idx: usize,
172        entry: &FragExtensionMethod,
173    ) {
174        if member_lists[idx]
175            .extension_methods
176            .iter()
177            .any(|(n, t, lo, hi)| {
178                *n == entry.name
179                    && *t == entry.this_type
180                    && *lo == entry.arity_min
181                    && *hi == entry.arity_max
182            })
183        {
184            return;
185        }
186        member_lists[idx].extension_methods.push((
187            entry.name.clone(),
188            entry.this_type.clone(),
189            entry.arity_min,
190            entry.arity_max,
191        ));
192        extension_index
193            .entry(format!("{} {}", entry.name, entry.this_type))
194            .or_default()
195            .push(ExtCandidate {
196                def_idx: idx,
197                entry: entry.clone(),
198            });
199    }
200
201    for (file, frag) in fragments_by_file {
202        for d in &frag.defs {
203            let def_key = (d.id.clone(), d.type_params.len());
204            match qualified_name_and_arity_to_def.get(&def_key) {
205                None => {
206                    let idx = defs.len();
207                    defs.push(Def {
208                        id: d.id.clone(),
209                        name: d.name.clone(),
210                        namespace: d.namespace.clone(),
211                        kind: d.kind.clone(),
212                        file: file.clone(),
213                        line: d.line,
214                        methods: d.methods.clone(),
215                        test_methods: d.test_methods.clone(),
216                        also_in: Vec::new(),
217                        end_line: d.end_line,
218                    });
219                    member_lists.push(MemberLists {
220                        properties: d.properties.clone(),
221                        fields: d.fields.clone(),
222                        extension_methods: Vec::new(),
223                        bases: d.bases.clone(),
224                        type_params: d.type_params.clone(),
225                        base_generic_args: d
226                            .base_generic_args
227                            .iter()
228                            .map(|(k, v)| (k.clone(), v.clone()))
229                            .collect(),
230                        method_returns: d.method_returns.clone(),
231                        property_types: d.property_types.clone(),
232                    });
233                    for e in &d.extension_methods {
234                        add_extension_method(&mut member_lists, &mut extension_index, idx, e);
235                    }
236                    qualified_name_to_def.entry(d.id.clone()).or_insert(idx);
237                    qualified_name_and_arity_to_def.insert(def_key, idx);
238                    // Enum members are reachable by exact id (below, via
239                    // qualified_name_to_def) but deliberately excluded from
240                    // simple_name_to_defs -- see module header.
241                    if d.kind != "enum-member" {
242                        simple_name_to_defs
243                            .entry(d.name.clone())
244                            .or_default()
245                            .push(idx);
246                    }
247                }
248                Some(&idx) => {
249                    // A partial class contributes its own members to the same
250                    // record; first declaring file wins the order, later ones
251                    // append what is new.
252                    defs[idx].also_in.push(AlsoIn {
253                        file: file.clone(),
254                        line: d.line,
255                    });
256                    for m in &d.methods {
257                        if !defs[idx].methods.contains(m) {
258                            defs[idx].methods.push(m.clone());
259                        }
260                    }
261                    // Test-coverage stage -- unioned exactly like `methods`
262                    // above it, so a partial test class split across two files
263                    // is one def declaring the union of both parts' tests.
264                    for t in &d.test_methods {
265                        if !defs[idx].test_methods.contains(t) {
266                            defs[idx].test_methods.push(t.clone());
267                        }
268                    }
269                    for p in &d.properties {
270                        if !member_lists[idx].properties.contains(p) {
271                            member_lists[idx].properties.push(p.clone());
272                        }
273                    }
274                    for f in &d.fields {
275                        if !member_lists[idx].fields.contains(f) {
276                            member_lists[idx].fields.push(f.clone());
277                        }
278                    }
279                    for e in &d.extension_methods {
280                        add_extension_method(&mut member_lists, &mut extension_index, idx, e);
281                    }
282                    for b in &d.bases {
283                        if !member_lists[idx].bases.contains(b) {
284                            member_lists[idx].bases.push(b.clone());
285                        }
286                    }
287                    // A partial class's later parts contribute the member types
288                    // the first part did not declare; a name the first part
289                    // already answered keeps its answer, the same
290                    // first-declaration-wins rule the extractor applies within
291                    // one declaration.
292                    for (name, returns) in d.method_returns.iter() {
293                        if member_lists[idx].method_returns.get(name).is_none() {
294                            member_lists[idx]
295                                .method_returns
296                                .insert(name.clone(), returns.clone());
297                        }
298                    }
299                    for (name, fact) in d.property_types.iter() {
300                        if member_lists[idx].property_types.get(name).is_none() {
301                            member_lists[idx]
302                                .property_types
303                                .insert(name.clone(), fact.clone());
304                        }
305                    }
306                }
307            }
308        }
309    }
310
311    // See `DefIndex::member_name_to_defs`. A second pass over the ALREADY-MERGED
312    // def records, so a partial class spread over three files lands in each of
313    // its member-name buckets exactly once.
314    let mut member_name_to_defs: HashMap<String, Vec<usize>> = HashMap::new();
315    for idx in 0..defs.len() {
316        let mut names: Vec<&str> = Vec::new();
317        let mut seen: HashSet<&str> = HashSet::new();
318        for m in &defs[idx].methods {
319            if seen.insert(m.as_str()) {
320                names.push(m.as_str());
321            }
322        }
323        for p in &member_lists[idx].properties {
324            if seen.insert(p.as_str()) {
325                names.push(p.as_str());
326            }
327        }
328        for f in &member_lists[idx].fields {
329            if seen.insert(f.as_str()) {
330                names.push(f.as_str());
331            }
332        }
333        for (name, ..) in &member_lists[idx].extension_methods {
334            if seen.insert(name.as_str()) {
335                names.push(name.as_str());
336            }
337        }
338        for n in names {
339            member_name_to_defs
340                .entry(n.to_string())
341                .or_default()
342                .push(idx);
343        }
344    }
345
346    DefIndex {
347        defs,
348        member_lists,
349        qualified_name_to_def,
350        qualified_name_and_arity_to_def,
351        simple_name_to_defs,
352        extension_index,
353        member_name_to_defs,
354    }
355}
356
357// A synthetic bare type reference, for re-running the ladder on a name the
358// RESOLVER derived rather than one it read off a ref: a base type, a receiver's
359// recorded type, a property's declared type, a callee's owner. Every field
360// except `name`/`outer_types` is what a bare, non-generic, non-member reference
361// carries, so a probe can never re-enter a member tier by accident.
362fn name_probe(name: String, namespace: &str, outer_types: Vec<String>) -> FragRef {
363    FragRef {
364        kind: "uses-type".to_string(),
365        name,
366        qualified: None,
367        member: None,
368        line: 0,
369        namespace: Some(namespace.to_string()),
370        type_arg_count: None,
371        generic: false,
372        receiver_type: None,
373        arg_count: None,
374        receiver_args: None,
375        outer_types,
376        args: None,
377        receiver_property_owner: None,
378        receiver_call_owner: None,
379        receiver_call_member: None,
380    }
381}
382
383// The def's member lists, unioned: methods ∪ properties ∪ fields, which is what
384// lets a static PROPERTY access (MessageUrn.Prefix) and a const/static FIELD
385// access earn an edge on the same evidence a static method call already did.
386fn declares_member(index: &DefIndex, idx: usize, member: Option<&str>) -> bool {
387    let Some(member) = member else { return false };
388    index.defs[idx].methods.iter().any(|m| m == member)
389        || index.member_lists[idx]
390            .properties
391            .iter()
392            .any(|p| p == member)
393        || index.member_lists[idx].fields.iter().any(|f| f == member)
394}
395
396// The membership test the SCORED tier uses: `declares_member` widened by the
397// extension-method names the def declares. A static class holding
398// `Render(this Widget w)` never "declares Render" in the instance sense
399// `declares_member` means, but it is exactly the def a `something.Render()`
400// guess should be allowed to name, so the scored tier counts it. Deliberately
401// NOT used by any precise tier: widening `declares_member` itself would let
402// tiers (a)/(e) emit a PRECISE edge on an extension name with none of tier
403// (f)'s arity, generic-unification or admission filters applied.
404fn member_vouched(index: &DefIndex, idx: usize, member: Option<&str>) -> bool {
405    if declares_member(index, idx, member) {
406        return true;
407    }
408    let Some(member) = member else { return false };
409    index.member_lists[idx]
410        .extension_methods
411        .iter()
412        .any(|(name, ..)| name == member)
413}
414
415// The whole scoring function, deterministic by construction and with no tie
416// left to chance: same namespace as the ref site beats a namespace the file
417// merely imports, which beats anything else. The global namespace is `""` on
418// BOTH sides here (the extractor records an empty string, never a missing key,
419// and `resolve_graph` folds a ref's absent namespace to `""` the same way), so
420// it matches itself and correctly scores 3.
421fn score_candidate(def_namespace: &str, ref_namespace: &str, usings: &HashSet<String>) -> u8 {
422    if def_namespace == ref_namespace {
423        return 3;
424    }
425    if usings.contains(def_namespace) {
426        return 2;
427    }
428    1
429}
430
431/// One file's using/alias context.
432struct FileContext {
433    usings: HashSet<String>,
434    aliases: HashMap<String, String>,
435}
436
437// Every file's own context (local ∪ every `global using`, with a local alias
438// shadowing a same-named global one), built once instead of once per ref. The
439// main loop needs it for the file it is walking; the instance-member veto needs
440// it for a DIFFERENT file -- the one that declares the base type it is resolving
441// -- which is why it is a map rather than two locals.
442fn build_file_contexts(
443    fragments_by_file: &[(String, Fragment)],
444    global_usings: &HashSet<String>,
445    global_aliases: &HashMap<String, String>,
446) -> HashMap<String, FileContext> {
447    let mut contexts = HashMap::new();
448    for (file, frag) in fragments_by_file {
449        let mut usings = global_usings.clone();
450        let mut aliases = global_aliases.clone();
451        for u in &frag.usings {
452            match u {
453                FragUsing::Alias { alias, target, .. } => {
454                    aliases.insert(alias.clone(), target.clone());
455                }
456                FragUsing::Plain { text, .. } => {
457                    usings.insert(text.clone());
458                }
459            }
460        }
461        contexts.insert(file.clone(), FileContext { usings, aliases });
462    }
463    contexts
464}
465
466// C#'s actual lookup rule: an INSTANCE member always beats an extension method,
467// and "instance member" means anything the receiver's type declares ANYWHERE in
468// its inheritance chain, not just on the type itself.
469//
470// True when `start` or any def in its transitive base closure declares
471// `member`. Each def's DIRECT base names come from the extraction-time `bases`
472// fact (the same base list the `inherits` refs are read from, reduced to base
473// identifiers) and are resolved LAZILY here -- through the ordinary ladder, in
474// the DECLARING file's own using/alias context and the def's own namespace,
475// because a base name means what it meant where it was written.
476//
477// Cycle-guarded by def index (C# forbids inheritance cycles, but a fragment
478// cache assembled from mid-edit sources can present one, and an infinite loop in
479// the resolver is not an acceptable failure mode). Only in-graph defs are
480// walked: an external base -- a BCL type, a NuGet type -- cannot be inspected,
481// so a member it declares cannot veto. That is the documented bound, and it is
482// the same one tier (e) already lives with.
483fn inheritance_walk_matches(
484    index: &DefIndex,
485    file_contexts: &HashMap<String, FileContext>,
486    start: usize,
487    mut matches: impl FnMut(usize) -> bool,
488) -> bool {
489    let mut seen: HashSet<usize> = HashSet::from([start]);
490    let mut stack: Vec<usize> = vec![start];
491    while let Some(cur) = stack.pop() {
492        if matches(cur) {
493            return true;
494        }
495        let Some(ctx) = file_contexts.get(&index.defs[cur].file) else {
496            continue;
497        };
498        let ns = index.defs[cur].namespace.clone();
499        for base in &index.member_lists[cur].bases {
500            // The base-closure probe carries no stack: it walks BASE types,
501            // not the lexical chain.
502            let probe = name_probe(base.clone(), &ns, Vec::new());
503            if let Resolution::Resolved(bidx, _) =
504                resolve_ref(&probe, &ctx.usings, &ns, index, &ctx.aliases, file_contexts)
505            {
506                if seen.insert(bidx) {
507                    stack.push(bidx);
508                }
509            }
510        }
511    }
512    false
513}
514
515fn inherited_member_declared(
516    index: &DefIndex,
517    file_contexts: &HashMap<String, FileContext>,
518    start: usize,
519    member: Option<&str>,
520) -> bool {
521    inheritance_walk_matches(index, file_contexts, start, |idx| {
522        declares_member(index, idx, member)
523    })
524}
525
526fn nested_candidate_visible_from_site(
527    ref_: &FragRef,
528    ns: &str,
529    candidate: usize,
530    index: &DefIndex,
531    file_contexts: &HashMap<String, FileContext>,
532) -> bool {
533    let Some((enclosing_id, _)) = index.defs[candidate].id.rsplit_once('+') else {
534        return true;
535    };
536    let Some(&enclosing_candidate) = index.qualified_name_to_def.get(enclosing_id) else {
537        return false;
538    };
539
540    for depth in (1..=ref_.outer_types.len()).rev() {
541        let enclosing_site = ref_.outer_types[..depth].join("+");
542        let enclosing_site = if ns.is_empty() {
543            enclosing_site
544        } else {
545            format!("{ns}.{enclosing_site}")
546        };
547        if let Some(&site_idx) = index.qualified_name_to_def.get(&enclosing_site) {
548            return inheritance_walk_matches(index, file_contexts, site_idx, |idx| {
549                idx == enclosing_candidate
550            });
551        }
552    }
553    false
554}
555
556// The call's argument count against the entry's declared RANGE. `arity_max ==
557// -1` is the `params` sentinel: unbounded above.
558fn arity_accepts(entry: &crate::graph::FragExtensionMethod, arg_count: usize) -> bool {
559    entry.arity_min <= arg_count && (entry.arity_max == -1 || (arg_count as i64) <= entry.arity_max)
560}
561
562// The this-parameter's top-level type arguments against the receiver's. Both
563// sides absent (neither type is generic) is the base-name match. Exactly one
564// side absent is a genuine generic/non-generic mismatch and never binds.
565// Otherwise the lists unify position by position, where "*" -- a type parameter
566// neither side can resolve to a concrete type -- matches anything.
567fn generic_args_unify(
568    this_args: Option<&Vec<String>>,
569    receiver_args: Option<&Vec<String>>,
570) -> bool {
571    match (this_args, receiver_args) {
572        (None, None) => true,
573        (Some(a), Some(b)) => {
574            a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x == "*" || y == "*" || x == y)
575        }
576        _ => false,
577    }
578}
579
580// ---------------------------------------------------------------------------
581// Global usings/aliases.
582// ---------------------------------------------------------------------------
583
584fn collect_global_usings(
585    fragments_by_file: &[(String, Fragment)],
586) -> (HashSet<String>, HashMap<String, String>) {
587    let mut global_usings: HashSet<String> = HashSet::new();
588    let mut global_aliases: HashMap<String, String> = HashMap::new();
589    for (_, frag) in fragments_by_file {
590        for u in &frag.usings {
591            match u {
592                FragUsing::Alias {
593                    alias,
594                    target,
595                    global,
596                } => {
597                    if *global {
598                        // First global alias for a given name wins -- NOT
599                        // last-wins. `entry(..).or_insert(..)` only writes on a
600                        // vacant slot.
601                        global_aliases
602                            .entry(alias.clone())
603                            .or_insert_with(|| target.clone());
604                    }
605                }
606                FragUsing::Plain { text, global } => {
607                    if *global {
608                        global_usings.insert(text.clone());
609                    }
610                }
611            }
612        }
613    }
614    (global_usings, global_aliases)
615}
616
617// ---------------------------------------------------------------------------
618// The ladder itself.
619// ---------------------------------------------------------------------------
620
621/// The `via` field on a resolved return -- names the ladder step that answered.
622/// Only the uses-member emission tiers consume it (an exact-qualified
623/// resolution is type-certain in a way a bare-name fallthrough is not); nothing
624/// in graph.json carries it.
625#[derive(Clone, Copy, PartialEq, Eq)]
626enum Via {
627    Alias,
628    Nested,
629    Qualified,
630    Usings,
631    Namespace,
632    Global,
633}
634
635enum Resolution {
636    Resolved(usize, Via),
637    Ambiguous(Vec<usize>),
638    External,
639}
640
641fn type_candidate(index: &DefIndex, name: &str, arity: Option<usize>) -> Option<usize> {
642    match arity {
643        Some(n) => index
644            .qualified_name_and_arity_to_def
645            .get(&(name.to_string(), n))
646            .copied(),
647        None => index.qualified_name_to_def.get(name).copied(),
648    }
649}
650
651// Resolve one ref (a type reference OR a uses-member qualifier -- same shape,
652// same ladder) against the current file's using/alias context. `ns` is
653// `ref.namespace` with `None` folded to `""`: an EMPTY namespace is treated the
654// same as absent for both the step-1 prefix walk and the step-3 same-namespace
655// check, so folding `None` to `""` up front avoids re-deriving that check at
656// every call site.
657fn resolve_ref(
658    ref_: &FragRef,
659    usings: &HashSet<String>,
660    ns: &str,
661    index: &DefIndex,
662    aliases: &HashMap<String, String>,
663    file_contexts: &HashMap<String, FileContext>,
664) -> Resolution {
665    // Every enclosing-namespace prefix of the reference site, innermost first
666    // and ending with the empty prefix (the name as literally written).
667    // Shared by steps 1, 2 and 3, all three of which walk it.
668    let segments: Vec<&str> = if ns.is_empty() {
669        Vec::new()
670    } else {
671        ns.split('.').collect()
672    };
673    let prefixes: Vec<String> = (0..=segments.len())
674        .rev()
675        .map(|i| segments[..i].join("."))
676        .collect();
677
678    // Step 0: alias short-circuit, bare names only.
679    if ref_.qualified.is_none() {
680        if let Some(alias_target) = aliases.get(&ref_.name) {
681            return match type_candidate(index, alias_target, ref_.type_arg_count) {
682                Some(idx) => Resolution::Resolved(idx, Via::Alias),
683                None => Resolution::External,
684            };
685        }
686        // Step 0b: the enclosing TYPE chain, longest prefix first (innermost
687        // out). A nested def id is its chain joined with "+" onto the ref's own
688        // namespace, so this is one exact id lookup per level and can never
689        // produce two candidates. A ref with no stack -- every namespace-level
690        // ref, every fragment cached without a type stack -- skips it.
691        for i in (1..=ref_.outer_types.len()).rev() {
692            let stack = ref_.outer_types[..i].join("+");
693            let candidate = if ns.is_empty() {
694                format!("{stack}+{}", ref_.name)
695            } else {
696                format!("{ns}.{stack}+{}", ref_.name)
697            };
698            if let Some(idx) = type_candidate(index, &candidate, ref_.type_arg_count) {
699                return Resolution::Resolved(idx, Via::Nested);
700            }
701        }
702    }
703
704    // Step 1: exact qualified name, walking enclosing namespaces innermost
705    // first, only for dotted references.
706    if let Some(qualified) = &ref_.qualified {
707        for prefix in &prefixes {
708            let candidate = if prefix.is_empty() {
709                qualified.clone()
710            } else {
711                format!("{prefix}.{qualified}")
712            };
713            if let Some(idx) = type_candidate(index, &candidate, ref_.type_arg_count) {
714                return Resolution::Resolved(idx, Via::Qualified);
715            }
716        }
717    }
718
719    // Step 2: file's usings (already the union of local + global by the time
720    // this is called) + simple name, the using's OWN name walked over every
721    // enclosing-namespace prefix -- `using Configuration;` inside
722    // `namespace A.B.C` reaches `A.Configuration.T`. One directive contributes
723    // at most ONE candidate: its innermost reading wins, exactly like step 1's
724    // first-match-wins walk, and only then does the 1-vs-many rule run across
725    // directives. Dedup by def id -- two different using texts landing on the
726    // same def counts once.
727    let mut using_matches: Vec<usize> = Vec::new();
728    let mut seen_ids: HashSet<&str> = HashSet::new();
729    for u in usings {
730        for prefix in &prefixes {
731            let candidate = if prefix.is_empty() {
732                format!("{u}.{}", ref_.name)
733            } else {
734                format!("{prefix}.{u}.{}", ref_.name)
735            };
736            if let Some(idx) = type_candidate(index, &candidate, ref_.type_arg_count) {
737                let id = index.defs[idx].id.as_str();
738                if seen_ids.insert(id) {
739                    using_matches.push(idx);
740                }
741                break;
742            }
743        }
744    }
745    if using_matches.len() == 1 {
746        return Resolution::Resolved(using_matches[0], Via::Usings);
747    }
748    if using_matches.len() >= 2 {
749        return Resolution::Ambiguous(using_matches);
750    }
751
752    // Step 3: the reference site's namespace AND every ancestor of it,
753    // innermost first -- the same walk step 1 runs (`T` inside `A.B.C`
754    // reaches `A.B.T` and `A.T`, not only `A.B.C.T`), which is C#'s
755    // ancestor-namespace rule.
756    for prefix in &prefixes {
757        let candidate = if prefix.is_empty() {
758            ref_.name.clone()
759        } else {
760            format!("{prefix}.{}", ref_.name)
761        };
762        if let Some(idx) = type_candidate(index, &candidate, ref_.type_arg_count) {
763            return Resolution::Resolved(idx, Via::Namespace);
764        }
765    }
766
767    // Step 4: globally unique simple name. Enum members are excluded from
768    // this pool (see build_def_index) -- a member named e.g. "Active"
769    // sharing a simple name with an unrelated class must not turn that
770    // class's previously-unambiguous references ambiguous. Nested definitions
771    // remain in the pool, but a bare reference can see one only when its
772    // enclosing type inherits from the nested definition's enclosing type.
773    let matches: Vec<usize> = index
774        .simple_name_to_defs
775        .get(&ref_.name)
776        .into_iter()
777        .flatten()
778        .copied()
779        .filter(|idx| {
780            ref_.type_arg_count
781                .map_or(true, |n| index.member_lists[*idx].type_params.len() == n)
782        })
783        .filter(|idx| {
784            ref_.qualified.is_some()
785                || nested_candidate_visible_from_site(ref_, ns, *idx, index, file_contexts)
786        })
787        .collect();
788    match matches.as_slice() {
789        [idx] => Resolution::Resolved(*idx, Via::Global),
790        [_, _, ..] => Resolution::Ambiguous(matches),
791        _ => Resolution::External,
792    }
793}
794
795// ---------------------------------------------------------------------------
796// Ambiguous-candidate capping.
797// ---------------------------------------------------------------------------
798
799// Sorted by id, capped at `AMBIGUOUS_CAP`, using plain Unicode-codepoint
800// `str::cmp` rather than locale-aware collation. For every id this extractor can
801// produce (C# namespace/type names -- letters, digits, underscore, `.`, `+`)
802// codepoint order and locale order coincide in the overwhelming common case
803// (PascalCase-leading identifiers, the C# naming convention this ladder's own
804// fixtures and every def id observed so far follow). A pathological mix of
805// leading-case or comparing `+` against a letter at the exact divergence point
806// could reorder (never change the SET of) candidates within the cap -- flagged,
807// not solved (no ICU collation available without a new dependency).
808fn capped_candidates(index: &DefIndex, mut candidate_indices: Vec<usize>) -> Vec<Candidate> {
809    candidate_indices.sort_by(|&a, &b| index.defs[a].id.cmp(&index.defs[b].id));
810    candidate_indices
811        .into_iter()
812        .take(AMBIGUOUS_CAP)
813        .map(|i| Candidate {
814            id: index.defs[i].id.clone(),
815            file: index.defs[i].file.clone(),
816        })
817        .collect()
818}
819
820// ---------------------------------------------------------------------------
821// Constructor-parameter DI resolution.
822// ---------------------------------------------------------------------------
823
824// Reverse index over EVERY def's `bases`: bare base name -> every def index
825// whose bases array names it. This is the raw candidate pool a ctor-param ref's
826// interface name is matched against; `resolve_ctor_param` confirms each
827// candidate the same way `inherited_member_declared` confirms a base name -- by
828// re-resolving it through the ladder in the CANDIDATE's own file context --
829// before trusting the bare-name match, so a same-named-but-unrelated interface
830// elsewhere in the corpus can never masquerade as an implementor.
831fn build_implementor_index(index: &DefIndex) -> HashMap<String, Vec<usize>> {
832    let mut map: HashMap<String, Vec<usize>> = HashMap::new();
833    for (idx, lists) in index.member_lists.iter().enumerate() {
834        for base_name in &lists.bases {
835            map.entry(base_name.clone()).or_default().push(idx);
836        }
837    }
838    map
839}
840
841// The "namespace prefix is System./Microsoft." half of the infra-leaf
842// classification. A heuristic, not a semantic check: scout parses no project
843// file and does no package-reference resolution, so this reads the reference
844// SITE's own usings (file-local ∪ global) rather than the unresolved type's
845// true origin -- a documented imprecision.
846fn is_infra_namespace(name: &str) -> bool {
847    name == "System"
848        || name == "Microsoft"
849        || name.starts_with("System.")
850        || name.starts_with("Microsoft.")
851}
852
853/// Outcome of resolving one 'ctor-param' ref, as a Rust enum -- the caller
854/// turns this into the `Edge::CtorDi` variant's fields.
855enum CtorDiResolution {
856    /// A single non-generic implementor of a non-generic interface.
857    Plain(usize),
858    /// A single non-generic implementor whose own base-list entry names the
859    /// SAME closed arguments the ctor param does.
860    Closed(usize),
861    /// A single implementor that is itself generic and passes the
862    /// interface's type argument straight through. Preferred only when no
863    /// closed implementor exists.
864    OpenGeneric(usize),
865    /// Two or more implementors tie at the same precedence tier -- never a
866    /// guess.
867    Ambiguous(Vec<usize>),
868    /// The interface itself does not resolve in the corpus AND the reference
869    /// site's usings name a BCL/Microsoft namespace: a framework leaf, not a
870    /// corpus gap.
871    Infra,
872    /// Every other case: the interface resolves in-corpus but nothing
873    /// implements it, or it resolves to nothing and no using suggests a
874    /// framework origin. Still EMITTED by the caller (never silently
875    /// dropped, unlike the general ladder's `unresolved_external` count).
876    Unresolved,
877}
878
879fn resolve_ctor_param(
880    ref_: &FragRef,
881    usings: &HashSet<String>,
882    ns: &str,
883    index: &DefIndex,
884    aliases: &HashMap<String, String>,
885    file_contexts: &HashMap<String, FileContext>,
886    implementors_by_base_name: &HashMap<String, Vec<usize>>,
887) -> CtorDiResolution {
888    match resolve_ref(ref_, usings, ns, index, aliases, file_contexts) {
889        Resolution::Ambiguous(candidate_indices) => CtorDiResolution::Ambiguous(candidate_indices),
890        Resolution::External => {
891            if usings.iter().any(|u| is_infra_namespace(u)) {
892                CtorDiResolution::Infra
893            } else {
894                CtorDiResolution::Unresolved
895            }
896        }
897        Resolution::Resolved(iface_idx, _) => {
898            let base_name = &ref_.name;
899            let raw_candidates = implementors_by_base_name
900                .get(base_name)
901                .cloned()
902                .unwrap_or_default();
903            let mut closed_or_plain: Vec<usize> = Vec::new();
904            let mut open_generic: Vec<usize> = Vec::new();
905            for cand in raw_candidates {
906                if cand == iface_idx {
907                    continue;
908                }
909                let cand_def = &index.defs[cand];
910                let Some(cand_ctx) = file_contexts.get(&cand_def.file) else {
911                    continue;
912                };
913                // Re-resolve the SAME base name through the CANDIDATE's own
914                // file context -- exactly inherited_member_declared's own
915                // pattern.
916                let probe = name_probe(base_name.clone(), &cand_def.namespace, Vec::new());
917                let base_res = resolve_ref(
918                    &probe,
919                    &cand_ctx.usings,
920                    &cand_def.namespace,
921                    index,
922                    &cand_ctx.aliases,
923                    file_contexts,
924                );
925                let Resolution::Resolved(resolved_iface, _) = base_res else {
926                    continue;
927                };
928                if resolved_iface != iface_idx {
929                    continue;
930                }
931                let cand_args = index.member_lists[cand]
932                    .base_generic_args
933                    .iter()
934                    .find(|(k, _)| k == base_name)
935                    .map(|(_, v)| v);
936                if !generic_args_unify(cand_args, ref_.args.as_ref()) {
937                    continue;
938                }
939                if cand_args
940                    .map(|a| a.iter().any(|x| x == "*"))
941                    .unwrap_or(false)
942                {
943                    open_generic.push(cand);
944                } else {
945                    closed_or_plain.push(cand);
946                }
947            }
948            if closed_or_plain.len() == 1 {
949                return if ref_.args.is_some() {
950                    CtorDiResolution::Closed(closed_or_plain[0])
951                } else {
952                    CtorDiResolution::Plain(closed_or_plain[0])
953                };
954            }
955            if closed_or_plain.len() > 1 {
956                return CtorDiResolution::Ambiguous(closed_or_plain);
957            }
958            if open_generic.len() == 1 {
959                return CtorDiResolution::OpenGeneric(open_generic[0]);
960            }
961            if open_generic.len() > 1 {
962                return CtorDiResolution::Ambiguous(open_generic);
963            }
964            CtorDiResolution::Unresolved
965        }
966    }
967}
968
969// ---------------------------------------------------------------------------
970// Top-level orchestration.
971// ---------------------------------------------------------------------------
972
973fn type_edge(kind: &str, file: &str, line: usize, target: &Def) -> Edge {
974    let from_file = file.to_string();
975    let to = target.id.clone();
976    let to_file = target.file.clone();
977    match kind {
978        "inherits" => Edge::Inherits {
979            from_file,
980            from_line: line,
981            to,
982            to_file,
983            heuristic: false,
984        },
985        _ => Edge::UsesType {
986            from_file,
987            from_line: line,
988            to,
989            to_file,
990            heuristic: false,
991        },
992    }
993}
994
995// The identity a heuristic edge is deduped on: everything its serialized form
996// carries. `None` for a precise edge, which is never a dedup subject. Field
997// order matches the edge's own, so two edges share a key exactly when they
998// serialize to the same bytes.
999fn heuristic_edge_key(e: &Edge) -> Option<String> {
1000    let (kind, from_file, from_line, to, to_file) = match e {
1001        Edge::Inherits {
1002            from_file,
1003            from_line,
1004            to,
1005            to_file,
1006            heuristic: true,
1007        } => ("inherits", from_file, from_line, to, to_file),
1008        Edge::UsesType {
1009            from_file,
1010            from_line,
1011            to,
1012            to_file,
1013            heuristic: true,
1014        } => ("uses-type", from_file, from_line, to, to_file),
1015        Edge::UsesMember {
1016            from_file,
1017            from_line,
1018            to,
1019            to_file,
1020            heuristic: true,
1021        } => ("uses-member", from_file, from_line, to, to_file),
1022        _ => return None,
1023    };
1024    Some(format!("{kind} {from_file} {from_line} {to} {to_file}"))
1025}
1026
1027/// Resolve C# fragments into a graph. Pure: `fragments_by_file` is
1028/// file-walk-ordered `(rel, Fragment)` pairs. No file I/O beyond the single
1029/// `git rev-parse HEAD` shell-out (`manifest::git_head`) that fills
1030/// `built_at_head` -- cheap enough to re-run on every `devscout map` whose C#
1031/// set changed, and unit-testable without a parser (see this module's tests,
1032/// which build `Fragment` values by hand).
1033pub fn resolve_graph(root: &Path, fragments_by_file: &[(String, Fragment)]) -> Graph {
1034    resolve_graph_with_ts(root, fragments_by_file, &[])
1035}
1036
1037/// The same resolve, with the TS/TSX half alongside. The caller passes the two
1038/// halves already split (it reads the tag off each cache entry --
1039/// `graph::AnyFragment`), and each half goes to its own resolver.
1040///
1041/// The TS contribution is a SUFFIX and never an interleave: def order, edge
1042/// order and the stats block a C#-only repo produces are untouched, and a
1043/// reader diffing two graphs sees the TS rows appended after every C# row. The
1044/// four TS edge kinds join `edges_by_kind` and `ts` joins `stats` ONLY when the
1045/// repo has a TS fragment at all.
1046pub fn resolve_graph_with_ts(
1047    root: &Path,
1048    fragments_by_file: &[(String, Fragment)],
1049    ts_fragments_by_file: &[(String, crate::extract::TsFragment)],
1050) -> Graph {
1051    let index = build_def_index(fragments_by_file);
1052    let (global_usings, global_aliases) = collect_global_usings(fragments_by_file);
1053    let file_contexts = build_file_contexts(fragments_by_file, &global_usings, &global_aliases);
1054    // Built once, not per-ref: every ctor-param ref's implementor lookup shares
1055    // this one reverse index.
1056    let implementors_by_base_name = build_implementor_index(&index);
1057
1058    let mut edges: Vec<Edge> = Vec::new();
1059    let mut edges_by_kind = EdgesByKind::default();
1060    let mut ambiguous_count: usize = 0;
1061    let mut unresolved_external: usize = 0;
1062    // Heuristic edges are counted HERE and nowhere else. `edges_by_kind` stays a
1063    // count of PRECISE edges only, so a consumer reading
1064    // `edges_by_kind['uses-member']` never has a guess folded into a fact. The
1065    // heuristic total is reported separately in the stats object.
1066    let mut heuristic_edge_count: usize = 0;
1067
1068    for (file, frag) in fragments_by_file {
1069        // Local alias shadows a same-named global one -- see
1070        // build_file_contexts, which builds every file's context once up front
1071        // so the veto walk can read a DIFFERENT file's context too.
1072        let FileContext { usings, aliases } = &file_contexts[file];
1073
1074        for r in &frag.refs {
1075            if r.kind == "imports" {
1076                edges.push(Edge::Imports {
1077                    from_file: file.clone(),
1078                    from_line: r.line,
1079                    target: r.name.clone(),
1080                });
1081                edges_by_kind.imports += 1;
1082                continue;
1083            }
1084
1085            let ns = r.namespace.as_deref().unwrap_or("");
1086
1087            if r.kind == "uses-member" {
1088                // Resolve the qualifier through the SAME ladder as a type
1089                // ref, then only act when it lands on exactly one candidate
1090                // that clears an emission tier. Enums emit unconditionally
1091                // (the member def is the target when it exists). Non-enum types
1092                // emit only on syntactic type-certainty, because a bare
1093                // qualifier that resolves to a type can still be an instance
1094                // property or local sharing the type's name:
1095                //   (a) the member is in the def's recorded member lists --
1096                //       methods, properties and fields
1097                //       (`MessageUrn.ForType(...)`, `MessageUrn.Prefix` -- an
1098                //       instance property named MessageUrn would not carry
1099                //       either), or
1100                //   (b) the qualifier carried a type-argument list (syntax no
1101                //       local/field/property can carry), or
1102                //   (c) the qualifier was dotted AND answered at the
1103                //       exact-qualified ladder step.
1104                // Everything else (ambiguous, external, or a non-enum
1105                // resolution with no certainty signal) is dropped silently
1106                // and deliberately NOT counted in ambiguous_count/
1107                // unresolved_external: almost every member access in a file
1108                // becomes a uses-member candidate (locals, properties, BCL
1109                // calls), and counting the misses would swamp the
1110                // type-ref-quality stats with noise ("never guess").
1111                //
1112                // The outcome is bound whole rather than pattern-matched
1113                // inline: the scored tier reads its STATUS (ambiguous vs.
1114                // nothing-at-all) to decide which candidate pool it may draw
1115                // from, and re-walking the ladder there would be a second
1116                // resolution of the same name in the same file context.
1117                let result = resolve_ref(r, usings, ns, &index, aliases, &file_contexts);
1118                let mut emitted = false;
1119                if let Resolution::Resolved(idx, via) = &result {
1120                    let (idx, via) = (*idx, *via);
1121                    if index.defs[idx].kind == "enum" {
1122                        let member_key = format!(
1123                            "{}.{}",
1124                            index.defs[idx].id,
1125                            r.member.as_deref().unwrap_or("")
1126                        );
1127                        let (to, to_file) = match index.qualified_name_to_def.get(&member_key) {
1128                            Some(&mi) => (index.defs[mi].id.clone(), index.defs[mi].file.clone()),
1129                            None => (index.defs[idx].id.clone(), index.defs[idx].file.clone()),
1130                        };
1131                        edges.push(Edge::UsesMember {
1132                            from_file: file.clone(),
1133                            from_line: r.line,
1134                            to,
1135                            to_file,
1136                            heuristic: false,
1137                        });
1138                        edges_by_kind.uses_member += 1;
1139                        emitted = true;
1140                    } else {
1141                        // generic counts only for BARE qualifiers: a
1142                        // flattened chain inherits the flag from its inner
1143                        // segment while ladder steps 2-4 resolve by the
1144                        // chain's TAIL name, which can name-match an
1145                        // unrelated type. Dotted
1146                        // chains earn their edge via the member lists or the
1147                        // exact-qualified step instead.
1148                        if declares_member(&index, idx, r.member.as_deref())
1149                            || (r.generic && r.qualified.is_none())
1150                            || (r.qualified.is_some() && via == Via::Qualified)
1151                        {
1152                            edges.push(Edge::UsesMember {
1153                                from_file: file.clone(),
1154                                from_line: r.line,
1155                                to: index.defs[idx].id.clone(),
1156                                to_file: index.defs[idx].file.clone(),
1157                                heuristic: false,
1158                            });
1159                            edges_by_kind.uses_member += 1;
1160                            emitted = true;
1161                        }
1162                    }
1163                }
1164                // Tier (e): the qualifier is an INSTANCE the extractor has
1165                // exactly one local type fact for (`_repo.Save()` where the
1166                // file declares `private IRepo _repo;`). The recorded type name
1167                // goes through the same ladder from the same file context -- it
1168                // is a bare identifier, so alias/usings/namespace/global all
1169                // apply -- and the member must be declared by the def it lands
1170                // on. Anything less (type unresolved, type ambiguous, member not
1171                // declared) is no edge; the extension tier may claim it, the
1172                // scored tier may tag it. Tried only after tiers (a)-(c) have
1173                // declined, so no ref can earn two edges.
1174                //
1175                // The resolution itself is hoisted into `receiver_def` so tier
1176                // (f)'s instance-member veto can reuse it instead of walking the
1177                // ladder a second time for the same name in the same file
1178                // context. Tier (e) still requires the member on the EXACT
1179                // receiver def, with no inheritance widening. The closure is a
1180                // negative signal only.
1181                //
1182                // The FULL outcome is kept too, not just the def: the scored
1183                // tier reads its status (ambiguous vs. nothing-at-all) for any
1184                // ref carrying a receiver fact, since that fact IS what the
1185                // qualifier's type is and outranks resolving the qualifier
1186                // identifier itself.
1187                let mut receiver_def: Option<usize> = None;
1188                let mut receiver_result: Option<Resolution> = None;
1189                // A `var x = Q.M(...)` local carries the CALL, not a type: the
1190                // extractor cannot know what `M` returns, and the def that can
1191                // is in another file. Resolving the callee's owner through the
1192                // same ladder and reading its recorded return type is what turns
1193                // the call into an ordinary receiver fact; from here on every
1194                // tier treats it as one. An owner that resolves to nothing or to
1195                // several candidates, and an owner with no recorded return for
1196                // that name, both yield no fact -- the local stays
1197                // taken-but-unknown, which is the answer the extractor already
1198                // gave.
1199                let mut receiver_type_name = r.receiver_type.clone();
1200                if receiver_type_name.is_none() {
1201                    if let (Some(owner), Some(member)) =
1202                        (&r.receiver_call_owner, &r.receiver_call_member)
1203                    {
1204                        let probe = name_probe(owner.clone(), ns, r.outer_types.clone());
1205                        if let Resolution::Resolved(oidx, _) =
1206                            resolve_ref(&probe, usings, ns, &index, aliases, &file_contexts)
1207                        {
1208                            receiver_type_name =
1209                                index.member_lists[oidx].method_returns.get(member).cloned();
1210                        }
1211                    }
1212                }
1213                if !emitted {
1214                    if let Some(receiver_type) = &receiver_type_name {
1215                        let probe = name_probe(receiver_type.clone(), ns, r.outer_types.clone());
1216                        let rr = resolve_ref(&probe, usings, ns, &index, aliases, &file_contexts);
1217                        if let Resolution::Resolved(ridx, _) = &rr {
1218                            let ridx = *ridx;
1219                            receiver_def = Some(ridx);
1220                            if declares_member(&index, ridx, r.member.as_deref()) {
1221                                edges.push(Edge::UsesMember {
1222                                    from_file: file.clone(),
1223                                    from_line: r.line,
1224                                    to: index.defs[ridx].id.clone(),
1225                                    to_file: index.defs[ridx].file.clone(),
1226                                    heuristic: false,
1227                                });
1228                                edges_by_kind.uses_member += 1;
1229                                // Tier (e) RECORDS its claim: the extension
1230                                // tier below reads `emitted`, and that is
1231                                // exactly what implements C#'s shadowing
1232                                // rule (see that tier's note).
1233                                emitted = true;
1234                            }
1235                        }
1236                        receiver_result = Some(rr);
1237                    }
1238                }
1239                // Tier (e2): the qualifier is a two-segment chain
1240                // whose head the extractor could type (`_widget.Config.Reload()`
1241                // where the file declares `private Widget _widget;`). The head
1242                // type goes through the same ladder tier (e) uses, its def's
1243                // recorded property types answer what the SECOND segment is,
1244                // and that answer goes through the ladder again -- so the hop is
1245                // the field/local hop run twice, with a def fact where the file
1246                // had no declaration to read.
1247                //
1248                // Every step must land on exactly one def and the member must be
1249                // declared by the type the property is declared as. A head that
1250                // resolves to nothing or to several, a property with no recorded
1251                // type (a predefined one records none), a property type that
1252                // resolves to nothing, and a member the property's type does not
1253                // declare all end the hop with no edge, exactly as tier (e) ends
1254                // on the same failures.
1255                //
1256                // The hop is deliberately precise-tier only: it does not feed
1257                // the extension tier's lookup key or the scored tier's pool,
1258                // both of which read `receiver_type_name`, which this tier never
1259                // sets. The property type is a fact about the property's
1260                // DECLARATION, and a guess built on top of a second hop is a
1261                // guess about a guess.
1262                if !emitted {
1263                    if let Some(owner) = &r.receiver_property_owner {
1264                        let probe = name_probe(owner.clone(), ns, r.outer_types.clone());
1265                        if let Resolution::Resolved(oidx, _) =
1266                            resolve_ref(&probe, usings, ns, &index, aliases, &file_contexts)
1267                        {
1268                            if let Some(fact) = index.member_lists[oidx].property_types.get(&r.name)
1269                            {
1270                                let hop =
1271                                    name_probe(fact.type_name.clone(), ns, r.outer_types.clone());
1272                                if let Resolution::Resolved(hidx, _) =
1273                                    resolve_ref(&hop, usings, ns, &index, aliases, &file_contexts)
1274                                {
1275                                    if declares_member(&index, hidx, r.member.as_deref()) {
1276                                        edges.push(Edge::UsesMember {
1277                                            from_file: file.clone(),
1278                                            from_line: r.line,
1279                                            to: index.defs[hidx].id.clone(),
1280                                            to_file: index.defs[hidx].file.clone(),
1281                                            heuristic: false,
1282                                        });
1283                                        edges_by_kind.uses_member += 1;
1284                                        emitted = true;
1285                                    }
1286                                }
1287                            }
1288                        }
1289                    }
1290                }
1291                // Tier (f): extension methods, by C#'s own lookup rule.
1292                //
1293                // This tier emits HEURISTIC edges (bucket, arity range, generic
1294                // unification, admission, veto, one-distinct-class rule); the
1295                // emitted edge gains `heuristic: true` and does not count toward
1296                // edges_by_kind. The reason is a STRUCTURAL bound found by the
1297                // corpus audit, not a loose filter: the instance-member veto can
1298                // only inspect in-graph types, and the receivers that matter
1299                // most in real code (BCL types, NuGet types, anything outside
1300                // the mapped scope) hide every member they declare. When such a
1301                // receiver's own type declares the member, C# binds the instance
1302                // member and this tier's edge is simply wrong -- and no no-build
1303                // veto can see it. That is unfixable without a compile, so the
1304                // honest move is to keep the edge and tag it as a guess rather
1305                // than delete a tier that is right far more often than not.
1306                //
1307                // Reached only when every earlier tier declined --
1308                // including tier (e), whose `emitted` flag is what makes the C#
1309                // SHADOWING rule fall out of tier order for free: when the
1310                // receiver's own type declares the member, tier (e) has already
1311                // claimed the ref and this tier never runs, exactly as the
1312                // compiler prefers an instance member over any extension
1313                // method.
1314                //
1315                // ONLY refs carrying a receiver fact qualify -- one the
1316                // extractor recorded, or one the call hop just produced --
1317                // and by construction rather than by a check: an extension
1318                // method is callable in instance-call syntax only, so a static
1319                // or namespace qualifier ("Utils.Helper()") must never reach
1320                // this tier, and such a ref carries no receiver fact, so the
1321                // lookup key cannot even be formed.
1322                //
1323                // Admission is the LANGUAGE's rule, not a proximity heuristic: a
1324                // candidate counts only when its declaring static class's
1325                // namespace is imported by this file (local or global using) or
1326                // IS this file's namespace. Exactly one admitted candidate
1327                // emits. Zero or two-or-more emit nothing and are NOT counted as
1328                // ambiguous -- the same silence every other uses-member miss
1329                // keeps, since counting them would swamp the type-ref-quality
1330                // stats (the scored tier may tag them instead).
1331                //
1332                // The ref must also carry an `argCount`, which is both an arity
1333                // test and a SHAPE test. A property read (`t.P`) records no
1334                // argCount at extraction, so it cannot form a key and never
1335                // enters this tier at all -- an extension method is only ever
1336                // reachable through call syntax. A call records one, and it has
1337                // to fall inside the candidate's declared [arityMin, arityMax]
1338                // range.
1339                //
1340                // Four filters, in this order. Every one of them can only ever
1341                // REMOVE a candidate, and the tier emits only on exactly one
1342                // survivor:
1343                //   1. the bucket -- exact (member name, thisType) pair;
1344                //   2. arity range -- arityMin <= argCount <= arityMax, where
1345                //      an arityMax of -1 (a trailing `params` array) is
1346                //      unbounded above;
1347                //   3. generic unification -- the this-parameter's top-level
1348                //      type arguments against the receiver's, with "*" (either
1349                //      side's own type parameters) matching anything, and a
1350                //      generic-vs-non-generic pairing never matching at all;
1351                //   4. admission -- the declaring static class's namespace is
1352                //      imported by this file (local or global using) or IS this
1353                //      file's namespace.
1354                // Candidates are counted as DISTINCT DECLARING CLASSES, not as
1355                // entries: an edge names the class, so two overloads of one
1356                // class both accepting this call agree on the answer and are
1357                // not an ambiguity. Two different classes are.
1358                //
1359                // On top of the filters, the instance-member VETO: if the
1360                // receiver resolves in-graph and the member is declared
1361                // anywhere in its inheritance closure, C# binds the instance
1362                // member and this tier must not claim the ref at all. Tier (e)
1363                // already implements the exact-type half of that rule by
1364                // claiming the ref first; the closure walk is what extends it
1365                // to inherited and interface-declared members, which tier (e)
1366                // deliberately does not widen to (it would start EMITTING edges
1367                // to the wrong def -- the base declares the member, the derived
1368                // type is what the code names).
1369                //
1370                // Three documented bounds, each with a pinning negative test:
1371                //   - thisType is matched by EXACT name. No base-class walk, no
1372                //     interface widening on the POSITIVE side: `this
1373                //     IEnumerable<T>` does not claim a receiver typed List,
1374                //     `this BaseWidget` does not claim one typed Widget.
1375                //   - the namespace test is exact too: a static class in an
1376                //     ENCLOSING namespace of the ref site (App.Ext visible from
1377                //     App.Ext.Deep) is not admitted, though real C# would.
1378                //     Narrower than the language, never wider.
1379                //   - the veto can only see IN-GRAPH types. An external
1380                //     receiver, or an external base of an in-graph receiver,
1381                //     hides whatever members it declares, so no veto is
1382                //     possible there.
1383                if !emitted {
1384                    if let (Some(receiver_type), Some(member), Some(arg_count)) =
1385                        (&receiver_type_name, r.member.as_deref(), r.arg_count)
1386                    {
1387                        let key = format!("{member} {receiver_type}");
1388                        let candidates: &[ExtCandidate] = index
1389                            .extension_index
1390                            .get(&key)
1391                            .map(Vec::as_slice)
1392                            .unwrap_or(&[]);
1393                        let mut distinct: Vec<usize> = Vec::new();
1394                        for c in candidates {
1395                            if !arity_accepts(&c.entry, arg_count) {
1396                                continue;
1397                            }
1398                            if !generic_args_unify(
1399                                c.entry.this_args.as_ref(),
1400                                r.receiver_args.as_ref(),
1401                            ) {
1402                                continue;
1403                            }
1404                            let def_ns = &index.defs[c.def_idx].namespace;
1405                            if !usings.contains(def_ns) && def_ns != ns {
1406                                continue;
1407                            }
1408                            if !distinct.contains(&c.def_idx) {
1409                                distinct.push(c.def_idx);
1410                            }
1411                        }
1412                        let vetoed = match receiver_def {
1413                            Some(ridx) => inherited_member_declared(
1414                                &index,
1415                                &file_contexts,
1416                                ridx,
1417                                r.member.as_deref(),
1418                            ),
1419                            None => false,
1420                        };
1421                        if distinct.len() == 1 && !vetoed {
1422                            let didx = distinct[0];
1423                            edges.push(Edge::UsesMember {
1424                                from_file: file.clone(),
1425                                from_line: r.line,
1426                                to: index.defs[didx].id.clone(),
1427                                to_file: index.defs[didx].file.clone(),
1428                                heuristic: true,
1429                            });
1430                            heuristic_edge_count += 1;
1431                            emitted = true;
1432                        }
1433                    }
1434                }
1435                // The SCORED tier, the last one, and the only one that may name
1436                // more than one def for a single ref. It
1437                // runs on refs no precise tier and not even tier (f) could
1438                // claim, and it never invents a candidate: the pool is always
1439                // something already recorded.
1440                //
1441                // Two mutually exclusive pools, chosen by what the ladder said
1442                // about the qualifier (for a bare/static qualifier) or about
1443                // the receiver's recorded type (whenever the ref carries a
1444                // receiverType fact -- that fact IS what the qualifier's type
1445                // is, so it outranks resolving the qualifier identifier
1446                // itself):
1447                //   - AMBIGUOUS: the ladder found several same-named defs and
1448                //     refused to pick. Those exact candidates, filtered to the
1449                //     ones that vouch for the member. This is the strong case
1450                //     -- the real answer is provably in the pool, only the
1451                //     choice is unknown.
1452                //   - NOTHING AT ALL (external/unresolved): fall back to
1453                //     member-name uniqueness -- every def graph-wide vouching
1454                //     for that member name, and ONLY when there are at most
1455                //     SCORED_UNIQUENESS_CAP of them. Past that threshold the
1456                //     name is common vocabulary (`Add`, `Name`, `Value`) and a
1457                //     guess carries no information, so the tier refuses
1458                //     outright rather than emitting its top three.
1459                // A qualifier that RESOLVED is deliberately in neither pool:
1460                // the resolution is a fact, the precise tiers already had their
1461                // chance at it, and a heuristic edge there would be a second
1462                // answer contradicting a known one. That is what keeps
1463                // "precise refs never get heuristic duplicates" true by
1464                // construction rather than by a later filter.
1465                //
1466                // Scoring is `score_candidate`; ties break on def id, ordinal
1467                // (the same `str::cmp` substitution `capped_candidates`
1468                // documents -- codepoint and locale order coincide for every
1469                // C# def id this extractor can produce). At most
1470                // SCORED_EMIT_CAP edges leave here, in scored order.
1471                //
1472                // A nested type (`Outer+Nested`) is not nameable from another
1473                // file without naming its outer type, so a guess landing on
1474                // one from outside its own file is unreachable by
1475                // construction. Same-file candidates stay -- inside the
1476                // declaring file the short name is real. The refusal happens
1477                // on the way OUT, after the cap: a refused guess gives up its
1478                // slot rather than promoting a weaker one into it.
1479                if !emitted {
1480                    let source: &Resolution = if receiver_type_name.is_some() {
1481                        receiver_result.as_ref().expect(
1482                            "a receiverType ref always resolves its receiver before this tier: tier (e) runs whenever !emitted",
1483                        )
1484                    } else {
1485                        &result
1486                    };
1487                    let pool: Option<Vec<usize>> = match source {
1488                        Resolution::Ambiguous(candidates) => Some(
1489                            candidates
1490                                .iter()
1491                                .copied()
1492                                .filter(|&d| member_vouched(&index, d, r.member.as_deref()))
1493                                .collect(),
1494                        ),
1495                        Resolution::External => {
1496                            let named: Vec<usize> = match r
1497                                .member
1498                                .as_deref()
1499                                .and_then(|m| index.member_name_to_defs.get(m))
1500                            {
1501                                Some(list) => list.clone(),
1502                                None => Vec::new(),
1503                            };
1504                            if named.len() <= SCORED_UNIQUENESS_CAP {
1505                                Some(named)
1506                            } else {
1507                                None
1508                            }
1509                        }
1510                        Resolution::Resolved(..) => None,
1511                    };
1512                    if let Some(pool) = pool {
1513                        let mut scored: Vec<(usize, u8)> = pool
1514                            .into_iter()
1515                            .map(|d| (d, score_candidate(&index.defs[d].namespace, ns, usings)))
1516                            .collect();
1517                        scored.sort_by(|a, b| {
1518                            b.1.cmp(&a.1)
1519                                .then_with(|| index.defs[a.0].id.cmp(&index.defs[b.0].id))
1520                        });
1521                        for (d, _) in scored.into_iter().take(SCORED_EMIT_CAP).filter(|&(d, _)| {
1522                            !index.defs[d].id.contains('+') || index.defs[d].file == *file
1523                        }) {
1524                            edges.push(Edge::UsesMember {
1525                                from_file: file.clone(),
1526                                from_line: r.line,
1527                                to: index.defs[d].id.clone(),
1528                                to_file: index.defs[d].file.clone(),
1529                                heuristic: true,
1530                            });
1531                            heuristic_edge_count += 1;
1532                        }
1533                    }
1534                }
1535                continue;
1536            }
1537
1538            // A 'ctor-param' ref never falls through to the generic
1539            // type-reference ladder below: that ladder resolves a bare name to
1540            // the INTERFACE's own def (which is what the plain 'uses-type' edge
1541            // for the same parameter already does), never to an IMPLEMENTATION.
1542            // This branch is the DI-specific resolution instead, and it always
1543            // emits (never silently drops, unlike unresolved_external below).
1544            if r.kind == "ctor-param" {
1545                let classification = resolve_ctor_param(
1546                    r,
1547                    usings,
1548                    ns,
1549                    &index,
1550                    aliases,
1551                    &file_contexts,
1552                    &implementors_by_base_name,
1553                );
1554                let (resolution, to, candidates): (&str, Option<String>, Vec<Candidate>) =
1555                    match classification {
1556                        CtorDiResolution::Plain(i) => {
1557                            ("plain", Some(index.defs[i].id.clone()), Vec::new())
1558                        }
1559                        CtorDiResolution::Closed(i) => {
1560                            ("closed", Some(index.defs[i].id.clone()), Vec::new())
1561                        }
1562                        CtorDiResolution::OpenGeneric(i) => {
1563                            ("open-generic", Some(index.defs[i].id.clone()), Vec::new())
1564                        }
1565                        CtorDiResolution::Ambiguous(idxs) => {
1566                            ("ambiguous", None, capped_candidates(&index, idxs))
1567                        }
1568                        CtorDiResolution::Infra => ("infra", None, Vec::new()),
1569                        CtorDiResolution::Unresolved => ("unresolved", None, Vec::new()),
1570                    };
1571                edges.push(Edge::CtorDi {
1572                    from_file: file.clone(),
1573                    from_line: r.line,
1574                    iface: r.name.clone(),
1575                    resolution: resolution.to_string(),
1576                    args: r.args.clone(),
1577                    to,
1578                    candidates,
1579                });
1580                edges_by_kind.ctor_di += 1;
1581                continue;
1582            }
1583
1584            match resolve_ref(r, usings, ns, &index, aliases, &file_contexts) {
1585                Resolution::Resolved(idx, _) => {
1586                    edges.push(type_edge(&r.kind, file, r.line, &index.defs[idx]));
1587                    match r.kind.as_str() {
1588                        "inherits" => edges_by_kind.inherits += 1,
1589                        "uses-type" => edges_by_kind.uses_type += 1,
1590                        _ => {}
1591                    }
1592                }
1593                Resolution::Ambiguous(candidate_indices) => {
1594                    let candidate_count = candidate_indices.len();
1595                    edges.push(Edge::Ambiguous {
1596                        origin: r.kind.clone(),
1597                        from_file: file.clone(),
1598                        from_line: r.line,
1599                        raw: r.name.clone(),
1600                        candidates: capped_candidates(&index, candidate_indices),
1601                        candidate_count,
1602                    });
1603                    ambiguous_count += 1;
1604                }
1605                Resolution::External => {
1606                    unresolved_external += 1;
1607                }
1608            }
1609        }
1610    }
1611
1612    // The full name index. Every name the mapped set declares, with the file
1613    // and line it is declared on: one entry per fragment def (its own `line`,
1614    // so `find` and `refs` point a caller at the same site), then that file's
1615    // member and markup names in source order. Types come off the FRAGMENT defs
1616    // rather than the merged rows, so a partial class contributes each declaring
1617    // site instead of only the first. Build order is fragment-map order, the
1618    // same order the edge loop above walks -- these bytes must be emitted in
1619    // that order or the artifacts diverge.
1620    //
1621    // A MARKUP def is the one def that contributes no row here. Its declaration
1622    // is already in the index, one entry earlier, as the `markup-class` name the
1623    // same scan emitted from the same `x:Class` on the same line -- under the
1624    // FULLY QUALIFIED spelling markup writes it in, which is strictly more than
1625    // a bare-name row would carry. Emitting both would put two rows on one
1626    // declaration and change what every existing `find` over a markup repo
1627    // returns.
1628    let mut names: Vec<GraphName> = Vec::new();
1629    for (file, frag) in fragments_by_file {
1630        if !crate::markup::is_markup(file) {
1631            for d in &frag.defs {
1632                names.push(GraphName {
1633                    name: d.name.clone(),
1634                    kind: d.kind.clone(),
1635                    file: file.clone(),
1636                    line: d.line,
1637                    owner: String::new(),
1638                });
1639            }
1640        }
1641        for n in &frag.names {
1642            names.push(GraphName {
1643                name: n.name.clone(),
1644                kind: n.kind.clone(),
1645                file: file.clone(),
1646                line: n.line,
1647                owner: n.owner.clone(),
1648            });
1649        }
1650    }
1651
1652    // Heuristic-side dedup, single pass, first occurrence wins. Independent
1653    // guess tiers (and repeated windows over one chain) can name the same
1654    // (kind, from_file, from_line, to, to_file) more than once; a second
1655    // byte-identical guess carries no information a reader can act on, so it
1656    // is dropped and its count with it. PRECISE edges are untouched: a
1657    // repeated precise edge is a repeated FACT about the source (two
1658    // references on one line), and collapsing it would silently lose a real
1659    // occurrence. `Vec::retain` keeps relative order, so the surviving first
1660    // occurrence sits exactly where it did.
1661    let mut seen_heuristic: HashSet<String> = HashSet::new();
1662    edges.retain(|e| match heuristic_edge_key(e) {
1663        None => true,
1664        Some(key) => {
1665            if seen_heuristic.insert(key) {
1666                true
1667            } else {
1668                heuristic_edge_count -= 1;
1669                false
1670            }
1671        }
1672    });
1673
1674    let type_ref_attempts = edges_by_kind.inherits + edges_by_kind.uses_type + ambiguous_count;
1675
1676    let mut graph = Graph {
1677        schema_version: 1,
1678        built_at_head: manifest::git_head(root),
1679        stats: Stats {
1680            def_count: index.defs.len(),
1681            file_count: fragments_by_file.len() + ts_fragments_by_file.len(),
1682            edges_by_kind,
1683            ambiguous_count,
1684            ambiguous_pct: Percent1::from_ratio(ambiguous_count, type_ref_attempts),
1685            unresolved_external_count: unresolved_external,
1686            heuristic_edge_count,
1687            // Appended LAST and always written, like the heuristic-edge counter
1688            // above it. Counts merged DEF ROWS, not fragment entries, so a
1689            // partial test class split across two files is one test def, not
1690            // two.
1691            test_def_count: index
1692                .defs
1693                .iter()
1694                .filter(|d| !d.test_methods.is_empty())
1695                .count(),
1696            ts: None,
1697        },
1698        defs: index.defs,
1699        edges,
1700        names,
1701    };
1702    if !ts_fragments_by_file.is_empty() {
1703        let alias = crate::tsgraph::read_ts_path_aliases(root);
1704        let ts = crate::tsgraph::resolve_ts_graph(ts_fragments_by_file, &alias);
1705        graph.defs.extend(ts.defs);
1706        graph.edges.extend(ts.edges);
1707        graph.stats.def_count = graph.defs.len();
1708        graph.stats.edges_by_kind.import = Some(ts.edges_by_kind.import);
1709        graph.stats.edges_by_kind.call = Some(ts.edges_by_kind.call);
1710        graph.stats.edges_by_kind.jsx_use = Some(ts.edges_by_kind.jsx_use);
1711        graph.stats.edges_by_kind.dispatch = Some(ts.edges_by_kind.dispatch);
1712        graph.stats.ts = Some(ts.stats);
1713    }
1714    graph
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719    use super::*;
1720    use crate::graph::FragDef;
1721
1722    /// Not a real git repo -- `resolve_graph`'s single I/O call (`git_head`)
1723    /// fails closed to `None` here, the same as a non-repo shell-out failure.
1724    fn no_git_root() -> std::path::PathBuf {
1725        std::env::temp_dir().join("scout-resolve-test-not-a-repo")
1726    }
1727
1728    fn def(id: &str, name: &str, ns: &str, kind: &str) -> FragDef {
1729        FragDef {
1730            id: id.into(),
1731            name: name.into(),
1732            namespace: ns.into(),
1733            kind: kind.into(),
1734            line: 1,
1735            methods: vec![],
1736            properties: vec![],
1737            fields: vec![],
1738            method_returns: crate::graph::OrderedMap::new(),
1739            extension_methods: vec![],
1740            bases: vec![],
1741            type_params: vec![],
1742            base_generic_args: crate::graph::OrderedMap::new(),
1743            test_methods: vec![],
1744            property_types: crate::graph::OrderedMap::new(),
1745            end_line: 0,
1746        }
1747    }
1748
1749    /// `def()` for a static class declaring extension methods -- the only input
1750    /// tier (f) reads besides the def's own namespace. The tuples are (name,
1751    /// this_type, arity_min, arity_max).
1752    fn ext_def(id: &str, name: &str, ns: &str, extensions: &[(&str, &str, usize, i64)]) -> FragDef {
1753        FragDef {
1754            extension_methods: extensions
1755                .iter()
1756                .map(|(n, t, lo, hi)| FragExtensionMethod {
1757                    name: (*n).to_string(),
1758                    this_type: (*t).to_string(),
1759                    arity_min: *lo,
1760                    arity_max: *hi,
1761                    this_args: None,
1762                })
1763                .collect(),
1764            ..def(id, name, ns, "class")
1765        }
1766    }
1767
1768    /// `def()` with member lists filled in -- the resolver reads
1769    /// `methods`/`properties`/`fields` as one union (tier (a)), so every tier
1770    /// test that turns on WHICH list a member lives in builds its def here.
1771    fn def_with(
1772        id: &str,
1773        name: &str,
1774        ns: &str,
1775        kind: &str,
1776        methods: &[&str],
1777        properties: &[&str],
1778        fields: &[&str],
1779    ) -> FragDef {
1780        FragDef {
1781            methods: methods.iter().map(|s| s.to_string()).collect(),
1782            properties: properties.iter().map(|s| s.to_string()).collect(),
1783            fields: fields.iter().map(|s| s.to_string()).collect(),
1784            ..def(id, name, ns, kind)
1785        }
1786    }
1787
1788    fn type_ref(kind: &str, name: &str, qualified: Option<&str>, ns: &str) -> FragRef {
1789        FragRef {
1790            kind: kind.into(),
1791            name: name.into(),
1792            qualified: qualified.map(String::from),
1793            member: None,
1794            line: 1,
1795            namespace: Some(ns.into()),
1796            type_arg_count: Some(0),
1797            generic: false,
1798            receiver_type: None,
1799            arg_count: None,
1800            receiver_args: None,
1801            outer_types: Vec::new(),
1802            args: None,
1803            receiver_property_owner: None,
1804            receiver_call_owner: None,
1805            receiver_call_member: None,
1806        }
1807    }
1808
1809    fn member_ref(name: &str, qualified: Option<&str>, member: &str, ns: &str) -> FragRef {
1810        FragRef {
1811            kind: "uses-member".into(),
1812            name: name.into(),
1813            qualified: qualified.map(String::from),
1814            member: Some(member.into()),
1815            line: 1,
1816            namespace: Some(ns.into()),
1817            type_arg_count: None,
1818            generic: false,
1819            receiver_type: None,
1820            arg_count: None,
1821            receiver_args: None,
1822            outer_types: Vec::new(),
1823            args: None,
1824            receiver_property_owner: None,
1825            receiver_call_owner: None,
1826            receiver_call_member: None,
1827        }
1828    }
1829
1830    /// A bare-qualifier member ref carrying the receiver fact the extractor
1831    /// would have recorded for it. `arg_count` is the CALL shape: `Some(n)` for
1832    /// `x.M(<n args>)`, `None` for a property read.
1833    fn receiver_ref(
1834        name: &str,
1835        member: &str,
1836        ns: &str,
1837        receiver_type: &str,
1838        arg_count: Option<usize>,
1839    ) -> FragRef {
1840        FragRef {
1841            receiver_type: Some(receiver_type.into()),
1842            arg_count,
1843            ..member_ref(name, None, member, ns)
1844        }
1845    }
1846
1847    fn frag(defs: Vec<FragDef>, usings: Vec<FragUsing>, refs: Vec<FragRef>) -> Fragment {
1848        Fragment {
1849            defs,
1850            usings,
1851            refs,
1852            names: Vec::new(),
1853        }
1854    }
1855
1856    fn find_edge<'a>(g: &'a Graph, want: impl Fn(&Edge) -> bool) -> Option<&'a Edge> {
1857        g.edges.iter().find(|e| want(e))
1858    }
1859
1860    // --- built_at_head threading (manifest::git_head unit-tested there;
1861    // this is the integration check that resolve_graph actually calls it
1862    // and plumbs the result into the right field) --------------------------
1863
1864    #[test]
1865    fn resolve_graph_threads_the_real_head_hash_through_built_at_head() {
1866        let nanos = std::time::SystemTime::now()
1867            .duration_since(std::time::UNIX_EPOCH)
1868            .unwrap()
1869            .as_nanos();
1870        let dir = std::env::temp_dir().join(format!("scout-resolve-head-test-{nanos}"));
1871        std::fs::create_dir_all(&dir).unwrap();
1872        let run = |args: &[&str]| {
1873            assert!(std::process::Command::new("git")
1874                .args(args)
1875                .current_dir(&dir)
1876                .status()
1877                .unwrap()
1878                .success());
1879        };
1880        run(&["init", "-q"]);
1881        run(&["config", "user.email", "test@example.com"]);
1882        run(&["config", "user.name", "Test"]);
1883        std::fs::write(dir.join("a.txt"), "hi").unwrap();
1884        run(&["add", "a.txt"]);
1885        run(&["commit", "-q", "-m", "one"]);
1886        let expected = manifest::git_head(&dir).expect("git_head must see the commit just made");
1887
1888        let g = resolve_graph(&dir, &[]);
1889        assert_eq!(g.built_at_head, Some(expected));
1890    }
1891
1892    #[test]
1893    fn resolve_graph_built_at_head_is_none_outside_any_repo() {
1894        let g = resolve_graph(&no_git_root(), &[]);
1895        assert_eq!(g.built_at_head, None);
1896    }
1897
1898    // --- alias short-circuit --------------------------------------------
1899
1900    #[test]
1901    fn alias_wins_over_an_otherwise_ambiguous_simple_name() {
1902        // Two "Money" classes plus an alias pinning "Cash" to one of them.
1903        // A bare reference to the ALIAS NAME must resolve cleanly even
1904        // though "Money" itself would be globally ambiguous.
1905        let files = vec![
1906            (
1907                "A/Money.cs".to_string(),
1908                frag(vec![def("A.Money", "Money", "A", "class")], vec![], vec![]),
1909            ),
1910            (
1911                "B/Money.cs".to_string(),
1912                frag(vec![def("B.Money", "Money", "B", "class")], vec![], vec![]),
1913            ),
1914            (
1915                "C/Wallet.cs".to_string(),
1916                frag(
1917                    vec![def("C.Wallet", "Wallet", "C", "class")],
1918                    vec![FragUsing::Alias {
1919                        alias: "Cash".into(),
1920                        target: "A.Money".into(),
1921                        global: false,
1922                    }],
1923                    vec![type_ref("uses-type", "Cash", None, "C")],
1924                ),
1925            ),
1926        ];
1927        let g = resolve_graph(&no_git_root(), &files);
1928        let edge =
1929            find_edge(&g, |e| matches!(e, Edge::UsesType { .. })).expect("resolved edge present");
1930        match edge {
1931            Edge::UsesType { to, .. } => assert_eq!(to, "A.Money"),
1932            _ => unreachable!(),
1933        }
1934        assert_eq!(g.stats.ambiguous_count, 0);
1935    }
1936
1937    #[test]
1938    fn local_alias_shadows_a_same_named_global_alias() {
1939        let files = vec![
1940            (
1941                "A/Money.cs".to_string(),
1942                frag(vec![def("A.Money", "Money", "A", "class")], vec![], vec![]),
1943            ),
1944            (
1945                "B/Money.cs".to_string(),
1946                frag(vec![def("B.Money", "Money", "B", "class")], vec![], vec![]),
1947            ),
1948            (
1949                "Globals.cs".to_string(),
1950                frag(
1951                    vec![],
1952                    vec![FragUsing::Alias {
1953                        alias: "Cash".into(),
1954                        target: "A.Money".into(),
1955                        global: true,
1956                    }],
1957                    vec![],
1958                ),
1959            ),
1960            (
1961                "C/Wallet.cs".to_string(),
1962                frag(
1963                    vec![def("C.Wallet", "Wallet", "C", "class")],
1964                    vec![FragUsing::Alias {
1965                        alias: "Cash".into(),
1966                        target: "B.Money".into(),
1967                        global: false,
1968                    }],
1969                    vec![type_ref("uses-type", "Cash", None, "C")],
1970                ),
1971            ),
1972            // A file with NO local override sees the global alias.
1973            (
1974                "D/Ledger.cs".to_string(),
1975                frag(
1976                    vec![def("D.Ledger", "Ledger", "D", "class")],
1977                    vec![],
1978                    vec![type_ref("uses-type", "Cash", None, "D")],
1979                ),
1980            ),
1981        ];
1982        let g = resolve_graph(&no_git_root(), &files);
1983        let shadowed = g
1984            .edges
1985            .iter()
1986            .find(|e| matches!(e, Edge::UsesType { from_file, .. } if from_file == "C/Wallet.cs"))
1987            .expect("shadowed edge present");
1988        let global = g
1989            .edges
1990            .iter()
1991            .find(|e| matches!(e, Edge::UsesType { from_file, .. } if from_file == "D/Ledger.cs"))
1992            .expect("global edge present");
1993        match (shadowed, global) {
1994            (
1995                Edge::UsesType {
1996                    to: shadowed_to, ..
1997                },
1998                Edge::UsesType { to: global_to, .. },
1999            ) => {
2000                assert_eq!(
2001                    shadowed_to, "B.Money",
2002                    "local alias must win over the global one"
2003                );
2004                assert_eq!(
2005                    global_to, "A.Money",
2006                    "no local override -- global alias applies"
2007                );
2008            }
2009            _ => unreachable!(),
2010        }
2011    }
2012
2013    // --- ambiguous marking (never guess) ---------------------------------
2014
2015    #[test]
2016    fn ambiguous_via_using_step_stops_before_reaching_global_uniqueness() {
2017        let files = vec![
2018            (
2019                "A/Money.cs".to_string(),
2020                frag(vec![def("A.Money", "Money", "A", "class")], vec![], vec![]),
2021            ),
2022            (
2023                "B/Money.cs".to_string(),
2024                frag(vec![def("B.Money", "Money", "B", "class")], vec![], vec![]),
2025            ),
2026            (
2027                "C/Statement.cs".to_string(),
2028                frag(
2029                    vec![def("C.Statement", "Statement", "C", "class")],
2030                    vec![
2031                        FragUsing::Plain {
2032                            text: "A".into(),
2033                            global: false,
2034                        },
2035                        FragUsing::Plain {
2036                            text: "B".into(),
2037                            global: false,
2038                        },
2039                    ],
2040                    vec![type_ref("uses-type", "Money", None, "C")],
2041                ),
2042            ),
2043        ];
2044        let g = resolve_graph(&no_git_root(), &files);
2045        assert_eq!(g.stats.ambiguous_count, 1);
2046        let edge = find_edge(&g, |e| matches!(e, Edge::Ambiguous { .. })).unwrap();
2047        match edge {
2048            Edge::Ambiguous {
2049                candidate_count,
2050                candidates,
2051                raw,
2052                ..
2053            } => {
2054                assert_eq!(*candidate_count, 2);
2055                assert_eq!(raw, "Money");
2056                assert_eq!(
2057                    candidates.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
2058                    vec!["A.Money", "B.Money"]
2059                );
2060            }
2061            _ => unreachable!(),
2062        }
2063    }
2064
2065    #[test]
2066    fn ambiguous_via_global_uniqueness_step_when_no_usings_apply() {
2067        let files = vec![
2068            (
2069                "A/Money.cs".to_string(),
2070                frag(vec![def("A.Money", "Money", "A", "class")], vec![], vec![]),
2071            ),
2072            (
2073                "B/Money.cs".to_string(),
2074                frag(vec![def("B.Money", "Money", "B", "class")], vec![], vec![]),
2075            ),
2076            (
2077                "C/Report.cs".to_string(),
2078                frag(
2079                    vec![def("C.Report", "Report", "C", "class")],
2080                    vec![],
2081                    vec![type_ref("uses-type", "Money", None, "C")],
2082                ),
2083            ),
2084        ];
2085        let g = resolve_graph(&no_git_root(), &files);
2086        assert_eq!(g.stats.ambiguous_count, 1);
2087        assert_eq!(g.stats.unresolved_external_count, 0);
2088    }
2089
2090    #[test]
2091    fn ambiguous_candidates_are_capped_at_five_sorted_by_id() {
2092        let mut files = Vec::new();
2093        for letter in ["E", "D", "C", "B", "A", "F", "G"] {
2094            files.push((
2095                format!("{letter}/Widget.cs"),
2096                frag(
2097                    vec![def(&format!("{letter}.Widget"), "Widget", letter, "class")],
2098                    vec![],
2099                    vec![],
2100                ),
2101            ));
2102        }
2103        files.push((
2104            "Z/Probe.cs".to_string(),
2105            frag(
2106                vec![def("Z.Probe", "Probe", "Z", "class")],
2107                vec![],
2108                vec![type_ref("uses-type", "Widget", None, "Z")],
2109            ),
2110        ));
2111        let g = resolve_graph(&no_git_root(), &files);
2112        let edge = g
2113            .edges
2114            .iter()
2115            .find(|e| matches!(e, Edge::Ambiguous { .. }))
2116            .unwrap();
2117        match edge {
2118            Edge::Ambiguous {
2119                candidate_count,
2120                candidates,
2121                ..
2122            } => {
2123                assert_eq!(*candidate_count, 7);
2124                assert_eq!(candidates.len(), 5, "capped at AMBIGUOUS_CAP");
2125                let ids: Vec<&str> = candidates.iter().map(|c| c.id.as_str()).collect();
2126                let mut sorted = ids.clone();
2127                sorted.sort();
2128                assert_eq!(ids, sorted, "candidates must be sorted by id");
2129            }
2130            _ => unreachable!(),
2131        }
2132    }
2133
2134    // --- enum-member asymmetry (the load-bearing case) --------------------
2135
2136    #[test]
2137    fn enum_member_id_is_reachable_via_qualified_name_to_def() {
2138        let files = vec![(
2139            "A/Status.cs".to_string(),
2140            frag(
2141                vec![
2142                    def("A.Status", "Status", "A", "enum"),
2143                    def("A.Status.Active", "Active", "A", "enum-member"),
2144                ],
2145                vec![],
2146                vec![],
2147            ),
2148        )];
2149        let index = build_def_index(&files);
2150        assert!(index.qualified_name_to_def.contains_key("A.Status.Active"));
2151    }
2152
2153    #[test]
2154    fn enum_member_does_not_collide_with_a_same_named_class_via_global_uniqueness() {
2155        // Regression trap: if enum members were NOT excluded from
2156        // simple_name_to_defs, "Active" would have two candidates (the
2157        // class AND the enum member) and this reference would incorrectly
2158        // come back ambiguous instead of resolving to the class.
2159        let files = vec![
2160            (
2161                "A/Status.cs".to_string(),
2162                frag(
2163                    vec![
2164                        def("A.Status", "Status", "A", "enum"),
2165                        def("A.Status.Active", "Active", "A", "enum-member"),
2166                    ],
2167                    vec![],
2168                    vec![],
2169                ),
2170            ),
2171            (
2172                "B/Active.cs".to_string(),
2173                frag(
2174                    vec![def("B.Active", "Active", "B", "class")],
2175                    vec![],
2176                    vec![],
2177                ),
2178            ),
2179            (
2180                "C/Toggle.cs".to_string(),
2181                frag(
2182                    vec![def("C.Toggle", "Toggle", "C", "class")],
2183                    vec![],
2184                    vec![type_ref("uses-type", "Active", None, "C")],
2185                ),
2186            ),
2187        ];
2188        let g = resolve_graph(&no_git_root(), &files);
2189        let edge = find_edge(&g, |e| matches!(e, Edge::UsesType { .. }))
2190            .expect("must resolve cleanly, not go ambiguous");
2191        match edge {
2192            Edge::UsesType { to, .. } => assert_eq!(to, "B.Active"),
2193            _ => unreachable!(),
2194        }
2195        assert_eq!(g.stats.ambiguous_count, 0);
2196    }
2197
2198    #[test]
2199    fn uses_member_edge_resolves_only_when_qualifier_is_an_enum() {
2200        let files = vec![
2201            (
2202                "A/Status.cs".to_string(),
2203                frag(
2204                    vec![
2205                        def("A.Status", "Status", "A", "enum"),
2206                        def("A.Status.Active", "Active", "A", "enum-member"),
2207                    ],
2208                    vec![],
2209                    vec![],
2210                ),
2211            ),
2212            (
2213                "B/Bundle.cs".to_string(),
2214                frag(
2215                    vec![def("B.Bundle", "Bundle", "B", "class")],
2216                    vec![],
2217                    vec![member_ref("Status", None, "Active", "A")], // same namespace as the enum
2218                ),
2219            ),
2220        ];
2221        let g = resolve_graph(&no_git_root(), &files);
2222        let edge = find_edge(&g, |e| matches!(e, Edge::UsesMember { .. }))
2223            .expect("uses-member edge present");
2224        match edge {
2225            Edge::UsesMember { to, to_file, .. } => {
2226                assert_eq!(to, "A.Status.Active");
2227                assert_eq!(to_file, "A/Status.cs");
2228            }
2229            _ => unreachable!(),
2230        }
2231        assert_eq!(g.stats.edges_by_kind.uses_member, 1);
2232    }
2233
2234    #[test]
2235    fn uses_member_on_a_non_enum_qualifier_is_dropped_silently() {
2236        let files = vec![
2237            (
2238                "A/Constants.cs".to_string(),
2239                frag(
2240                    vec![def("A.Constants", "Constants", "A", "class")],
2241                    vec![],
2242                    vec![],
2243                ),
2244            ),
2245            (
2246                "B/View.cs".to_string(),
2247                frag(
2248                    vec![def("B.View", "View", "B", "class")],
2249                    vec![FragUsing::Plain {
2250                        text: "A".into(),
2251                        global: false,
2252                    }],
2253                    vec![member_ref("Constants", None, "MaxRetries", "B")],
2254                ),
2255            ),
2256        ];
2257        let g = resolve_graph(&no_git_root(), &files);
2258        assert!(g
2259            .edges
2260            .iter()
2261            .all(|e| !matches!(e, Edge::UsesMember { .. })));
2262        // Silently dropped -- not counted as ambiguous or external.
2263        assert_eq!(g.stats.ambiguous_count, 0);
2264        assert_eq!(g.stats.unresolved_external_count, 0);
2265    }
2266
2267    // --- multi-part (qualified) member-access qualifiers ---
2268
2269    #[test]
2270    fn qualified_multipart_member_qualifier_resolves_via_exact_fqn_ladder_step() {
2271        let files = vec![
2272            (
2273                "Enums/MyEnum.cs".to_string(),
2274                frag(
2275                    vec![
2276                        def("Some.Namespace.MyEnum", "MyEnum", "Some.Namespace", "enum"),
2277                        def(
2278                            "Some.Namespace.MyEnum.Member",
2279                            "Member",
2280                            "Some.Namespace",
2281                            "enum-member",
2282                        ),
2283                    ],
2284                    vec![],
2285                    vec![],
2286                ),
2287            ),
2288            (
2289                "Consumers/Reader.cs".to_string(),
2290                frag(
2291                    vec![def(
2292                        "App.Consumers.Reader",
2293                        "Reader",
2294                        "App.Consumers",
2295                        "class",
2296                    )],
2297                    vec![],
2298                    vec![member_ref(
2299                        "MyEnum",
2300                        Some("Some.Namespace.MyEnum"),
2301                        "Member",
2302                        "App.Consumers",
2303                    )],
2304                ),
2305            ),
2306        ];
2307        let g = resolve_graph(&no_git_root(), &files);
2308        let edge = find_edge(&g, |e| matches!(e, Edge::UsesMember { .. }))
2309            .expect("uses-member edge present");
2310        match edge {
2311            Edge::UsesMember { to, to_file, .. } => {
2312                assert_eq!(to, "Some.Namespace.MyEnum.Member");
2313                assert_eq!(to_file, "Enums/MyEnum.cs");
2314            }
2315            _ => unreachable!(),
2316        }
2317        assert_eq!(g.stats.edges_by_kind.uses_member, 1);
2318    }
2319
2320    #[test]
2321    fn namespace_alias_qualified_member_ref_resolves_only_via_global_uniqueness_not_a_genuine_alias_walk(
2322    ) {
2323        // Resolution-ladder subtlety: step 0 (the alias short-circuit) only
2324        // ever fires for a BARE, non-dotted ref. "Ns.MyEnum" is dotted the
2325        // moment it has 2+ segments, so "Ns" is never looked up in the alias
2326        // map -- this resolves purely because "MyEnum" happens to be
2327        // globally unique (step 4), not genuine alias resolution.
2328        let files = vec![
2329            (
2330                "Enums/MyEnum.cs".to_string(),
2331                frag(
2332                    vec![
2333                        def("Some.Namespace.MyEnum", "MyEnum", "Some.Namespace", "enum"),
2334                        def(
2335                            "Some.Namespace.MyEnum.Member",
2336                            "Member",
2337                            "Some.Namespace",
2338                            "enum-member",
2339                        ),
2340                    ],
2341                    vec![],
2342                    vec![],
2343                ),
2344            ),
2345            (
2346                "Consumers/AliasNsUser.cs".to_string(),
2347                frag(
2348                    vec![def(
2349                        "App.Consumers.AliasNsUser",
2350                        "AliasNsUser",
2351                        "App.Consumers",
2352                        "class",
2353                    )],
2354                    vec![FragUsing::Alias {
2355                        alias: "Ns".into(),
2356                        target: "Some.Namespace".into(),
2357                        global: false,
2358                    }],
2359                    vec![member_ref(
2360                        "MyEnum",
2361                        Some("Ns.MyEnum"),
2362                        "Member",
2363                        "App.Consumers",
2364                    )],
2365                ),
2366            ),
2367        ];
2368        let g = resolve_graph(&no_git_root(), &files);
2369        let edge = find_edge(&g, |e| matches!(e, Edge::UsesMember { .. }))
2370            .expect("resolves via step-4 global uniqueness of MyEnum, not the Ns alias");
2371        match edge {
2372            Edge::UsesMember { to, .. } => assert_eq!(to, "Some.Namespace.MyEnum.Member"),
2373            _ => unreachable!(),
2374        }
2375    }
2376
2377    // --- non-enum emission tiers ---
2378
2379    #[test]
2380    fn dotted_exact_qualified_member_access_to_a_static_class_emits_uses_member_edge() {
2381        let files = vec![
2382            (
2383                "Other/Utils.cs".to_string(),
2384                frag(
2385                    vec![def("App.Other.Utils", "Utils", "App.Other", "class")],
2386                    vec![],
2387                    vec![],
2388                ),
2389            ),
2390            (
2391                "Consumers/UsesNonEnum.cs".to_string(),
2392                frag(
2393                    vec![def(
2394                        "App.Consumers.UsesNonEnum",
2395                        "UsesNonEnum",
2396                        "App.Consumers",
2397                        "class",
2398                    )],
2399                    vec![],
2400                    vec![member_ref(
2401                        "Utils",
2402                        Some("App.Other.Utils"),
2403                        "MaxRetries",
2404                        "App.Consumers",
2405                    )],
2406                ),
2407            ),
2408        ];
2409        let g = resolve_graph(&no_git_root(), &files);
2410        let edge = find_edge(&g, |e| matches!(e, Edge::UsesMember { .. }))
2411            .expect("exact-qualified static access emits");
2412        match edge {
2413            Edge::UsesMember { to, .. } => assert_eq!(
2414                to, "App.Other.Utils",
2415                "targets the type def -- member defs exist only for enums"
2416            ),
2417            _ => unreachable!(),
2418        }
2419        assert_eq!(g.stats.edges_by_kind.uses_member, 1);
2420        assert_eq!(
2421            g.stats.ambiguous_count, 0,
2422            "uses-member misses must never be reported as ambiguous"
2423        );
2424        assert_eq!(g.stats.unresolved_external_count, 0);
2425    }
2426
2427    #[test]
2428    fn bare_qualifier_to_a_class_emits_only_when_the_member_is_a_recorded_method() {
2429        let urn = def_with(
2430            "App.Other.MessageUrn",
2431            "MessageUrn",
2432            "App.Other",
2433            "class",
2434            &["ForType"],
2435            &[],
2436            &[],
2437        );
2438        let files = vec![
2439            (
2440                "Other/MessageUrn.cs".to_string(),
2441                frag(vec![urn], vec![], vec![]),
2442            ),
2443            (
2444                "Consumers/CallsStatic.cs".to_string(),
2445                frag(
2446                    vec![def(
2447                        "App.Consumers.CallsStatic",
2448                        "CallsStatic",
2449                        "App.Consumers",
2450                        "class",
2451                    )],
2452                    vec![FragUsing::Plain {
2453                        text: "App.Other".into(),
2454                        global: false,
2455                    }],
2456                    vec![
2457                        member_ref("MessageUrn", None, "ForType", "App.Consumers"),
2458                        member_ref("MessageUrn", None, "SomeUnknownField", "App.Consumers"),
2459                    ],
2460                ),
2461            ),
2462        ];
2463        let g = resolve_graph(&no_git_root(), &files);
2464        assert_eq!(
2465            g.stats.edges_by_kind.uses_member, 1,
2466            "the method call emits; the unknown-member access does not (could be a same-named property)"
2467        );
2468        match find_edge(&g, |e| matches!(e, Edge::UsesMember { .. })).unwrap() {
2469            Edge::UsesMember { to, .. } => assert_eq!(to, "App.Other.MessageUrn"),
2470            _ => unreachable!(),
2471        }
2472    }
2473
2474    #[test]
2475    fn generic_qualifier_emits_because_type_argument_syntax_cannot_be_a_local_or_property() {
2476        let mut generic_ref = member_ref("TypeCache", None, "Cached", "App.Consumers");
2477        generic_ref.generic = true;
2478        let files = vec![
2479            (
2480                "Other/TypeCache.cs".to_string(),
2481                frag(
2482                    vec![def(
2483                        "App.Other.TypeCache",
2484                        "TypeCache",
2485                        "App.Other",
2486                        "class",
2487                    )],
2488                    vec![],
2489                    vec![],
2490                ),
2491            ),
2492            (
2493                "Consumers/UsesCache.cs".to_string(),
2494                frag(
2495                    vec![def(
2496                        "App.Consumers.UsesCache",
2497                        "UsesCache",
2498                        "App.Consumers",
2499                        "class",
2500                    )],
2501                    vec![FragUsing::Plain {
2502                        text: "App.Other".into(),
2503                        global: false,
2504                    }],
2505                    vec![generic_ref],
2506                ),
2507            ),
2508        ];
2509        let g = resolve_graph(&no_git_root(), &files);
2510        assert_eq!(
2511            g.stats.edges_by_kind.uses_member, 1,
2512            "a generic qualifier is type-certain even for a property member"
2513        );
2514        match find_edge(&g, |e| matches!(e, Edge::UsesMember { .. })).unwrap() {
2515            Edge::UsesMember { to, .. } => assert_eq!(to, "App.Other.TypeCache"),
2516            _ => unreachable!(),
2517        }
2518    }
2519
2520    #[test]
2521    fn dotted_chain_with_inherited_generic_flag_does_not_emit_via_tail_name_match() {
2522        // "EqualityComparer<TSaga>.Default.GetHashCode(...)" shape: the
2523        // flattened qualifier "EqualityComparer.Default" carries generic=true
2524        // from its inner segment; its TAIL name "Default" happens to match a
2525        // real type. Gate-audit regression: no edge.
2526        let mut chain_ref = member_ref(
2527            "Default",
2528            Some("EqualityComparer.Default"),
2529            "GetHashCode",
2530            "App.Consumers",
2531        );
2532        chain_ref.generic = true;
2533        let files = vec![
2534            (
2535                "Other/Default.cs".to_string(),
2536                frag(
2537                    vec![def("App.Other.Default", "Default", "App.Other", "class")],
2538                    vec![],
2539                    vec![],
2540                ),
2541            ),
2542            (
2543                "Consumers/Chain.cs".to_string(),
2544                frag(
2545                    vec![def(
2546                        "App.Consumers.Chain",
2547                        "Chain",
2548                        "App.Consumers",
2549                        "class",
2550                    )],
2551                    vec![],
2552                    vec![chain_ref],
2553                ),
2554            ),
2555        ];
2556        let g = resolve_graph(&no_git_root(), &files);
2557        assert!(
2558            g.edges
2559                .iter()
2560                .all(|e| !matches!(e, Edge::UsesMember { .. })),
2561            "chain-tail name match must not emit"
2562        );
2563    }
2564
2565    #[test]
2566    fn bare_non_method_member_on_a_non_enum_qualifier_is_still_dropped_silently() {
2567        let files = vec![
2568            (
2569                "Other/Widget.cs".to_string(),
2570                frag(
2571                    vec![def("App.Other.Widget", "Widget", "App.Other", "class")],
2572                    vec![],
2573                    vec![],
2574                ),
2575            ),
2576            (
2577                "Consumers/PropLike.cs".to_string(),
2578                frag(
2579                    vec![def(
2580                        "App.Consumers.PropLike",
2581                        "PropLike",
2582                        "App.Consumers",
2583                        "class",
2584                    )],
2585                    vec![FragUsing::Plain {
2586                        text: "App.Other".into(),
2587                        global: false,
2588                    }],
2589                    vec![member_ref("Widget", None, "Name", "App.Consumers")],
2590                ),
2591            ),
2592        ];
2593        let g = resolve_graph(&no_git_root(), &files);
2594        assert!(
2595            g.edges
2596                .iter()
2597                .all(|e| !matches!(e, Edge::UsesMember { .. })),
2598            "no certainty signal -> no edge (could be an instance property named Widget)"
2599        );
2600        assert_eq!(g.stats.ambiguous_count, 0);
2601        assert_eq!(g.stats.unresolved_external_count, 0);
2602    }
2603
2604    #[test]
2605    fn nested_enum_dotted_qualifier_resolves_via_global_uniqueness_not_the_plus_joined_id() {
2606        // The "+"-joined nested-type id ("App.Widgets.Outer+Inner") never
2607        // matches the literal dotted source text ("Outer.Inner") at ladder
2608        // step 1 -- same as an ordinary nested TYPE reference, this only
2609        // resolves via step 4 (globally unique simple name "Inner").
2610        let files = vec![
2611            (
2612                "Enums/Container.cs".to_string(),
2613                frag(
2614                    vec![
2615                        def("App.Widgets.Outer", "Outer", "App.Widgets", "class"),
2616                        def("App.Widgets.Outer+Inner", "Inner", "App.Widgets", "enum"),
2617                        def(
2618                            "App.Widgets.Outer+Inner.On",
2619                            "On",
2620                            "App.Widgets",
2621                            "enum-member",
2622                        ),
2623                    ],
2624                    vec![],
2625                    vec![],
2626                ),
2627            ),
2628            (
2629                "Consumers/NestedUser.cs".to_string(),
2630                frag(
2631                    vec![def(
2632                        "App.Consumers.NestedUser",
2633                        "NestedUser",
2634                        "App.Consumers",
2635                        "class",
2636                    )],
2637                    vec![],
2638                    vec![member_ref(
2639                        "Inner",
2640                        Some("Outer.Inner"),
2641                        "On",
2642                        "App.Consumers",
2643                    )],
2644                ),
2645            ),
2646        ];
2647        let g = resolve_graph(&no_git_root(), &files);
2648        let edge = find_edge(&g, |e| matches!(e, Edge::UsesMember { .. }))
2649            .expect("uses-member edge present");
2650        match edge {
2651            Edge::UsesMember { to, to_file, .. } => {
2652                assert_eq!(to, "App.Widgets.Outer+Inner.On");
2653                assert_eq!(to_file, "Enums/Container.cs");
2654            }
2655            _ => unreachable!(),
2656        }
2657    }
2658
2659    // --- declaration_expression -> uses-type ref, same ladder ---
2660
2661    #[test]
2662    fn declaration_expression_type_ref_resolves_through_the_normal_uses_type_ladder() {
2663        let files = vec![
2664            (
2665                "Enums/PostType.cs".to_string(),
2666                frag(
2667                    vec![def("App.Enums.PostType", "PostType", "App.Enums", "enum")],
2668                    vec![],
2669                    vec![],
2670                ),
2671            ),
2672            (
2673                "Consumers/OutUser.cs".to_string(),
2674                frag(
2675                    vec![def(
2676                        "App.Consumers.OutUser",
2677                        "OutUser",
2678                        "App.Consumers",
2679                        "class",
2680                    )],
2681                    vec![FragUsing::Plain {
2682                        text: "App.Enums".into(),
2683                        global: false,
2684                    }],
2685                    vec![type_ref("uses-type", "PostType", None, "App.Consumers")],
2686                ),
2687            ),
2688        ];
2689        let g = resolve_graph(&no_git_root(), &files);
2690        let edge =
2691            find_edge(&g, |e| matches!(e, Edge::UsesType { .. })).expect("resolved edge present");
2692        match edge {
2693            Edge::UsesType { to, .. } => assert_eq!(to, "App.Enums.PostType"),
2694            _ => unreachable!(),
2695        }
2696    }
2697
2698    #[test]
2699    fn declaration_expression_type_ref_with_ambiguous_simple_name_is_marked_ambiguous() {
2700        let files = vec![
2701            (
2702                "A/Status.cs".to_string(),
2703                frag(
2704                    vec![def("A.Status", "Status", "A", "class")],
2705                    vec![],
2706                    vec![],
2707                ),
2708            ),
2709            (
2710                "B/Status.cs".to_string(),
2711                frag(
2712                    vec![def("B.Status", "Status", "B", "class")],
2713                    vec![],
2714                    vec![],
2715                ),
2716            ),
2717            (
2718                "Consumers/AmbiguousOutUser.cs".to_string(),
2719                frag(
2720                    vec![def(
2721                        "App.Consumers.AmbiguousOutUser",
2722                        "AmbiguousOutUser",
2723                        "App.Consumers",
2724                        "class",
2725                    )],
2726                    vec![],
2727                    vec![type_ref("uses-type", "Status", None, "App.Consumers")],
2728                ),
2729            ),
2730        ];
2731        let g = resolve_graph(&no_git_root(), &files);
2732        let edge =
2733            find_edge(&g, |e| matches!(e, Edge::Ambiguous { .. })).expect("ambiguous edge present");
2734        match edge {
2735            Edge::Ambiguous {
2736                origin,
2737                raw,
2738                candidate_count,
2739                ..
2740            } => {
2741                assert_eq!(origin, "uses-type");
2742                assert_eq!(raw, "Status");
2743                assert_eq!(*candidate_count, 2);
2744            }
2745            _ => unreachable!(),
2746        }
2747    }
2748
2749    // --- qualified (dotted) resolution: enclosing-namespace walk ----------
2750
2751    #[test]
2752    fn qualified_reference_resolves_at_an_outer_enclosing_namespace_prefix() {
2753        // From within `Fixtures.Billing` (2 segments), a reference to
2754        // `Common.IIdentifiable` must find `Fixtures.Common.IIdentifiable`
2755        // by trying prefix "Fixtures" (outer), after "Fixtures.Billing"
2756        // (innermost) fails -- exercises the walk, not just a literal or
2757        // innermost-only match.
2758        let files = vec![
2759            (
2760                "Common/IIdentifiable.cs".to_string(),
2761                frag(
2762                    vec![def(
2763                        "Fixtures.Common.IIdentifiable",
2764                        "IIdentifiable",
2765                        "Fixtures.Common",
2766                        "interface",
2767                    )],
2768                    vec![],
2769                    vec![],
2770                ),
2771            ),
2772            (
2773                "Billing/Invoice.cs".to_string(),
2774                frag(
2775                    vec![def(
2776                        "Fixtures.Billing.Invoice",
2777                        "Invoice",
2778                        "Fixtures.Billing",
2779                        "class",
2780                    )],
2781                    vec![],
2782                    vec![type_ref(
2783                        "inherits",
2784                        "IIdentifiable",
2785                        Some("Common.IIdentifiable"),
2786                        "Fixtures.Billing",
2787                    )],
2788                ),
2789            ),
2790        ];
2791        let g = resolve_graph(&no_git_root(), &files);
2792        let edge = find_edge(&g, |e| matches!(e, Edge::Inherits { .. }))
2793            .expect("resolved via namespace walk");
2794        match edge {
2795            Edge::Inherits { to, .. } => assert_eq!(to, "Fixtures.Common.IIdentifiable"),
2796            _ => unreachable!(),
2797        }
2798    }
2799
2800    #[test]
2801    fn qualified_reference_with_no_matching_prefix_and_no_literal_match_is_external() {
2802        let files = vec![(
2803            "A/Probe.cs".to_string(),
2804            frag(
2805                vec![def("A.Probe", "Probe", "A", "class")],
2806                vec![],
2807                vec![type_ref("uses-type", "Y", Some("X.Y"), "A")],
2808            ),
2809        )];
2810        let g = resolve_graph(&no_git_root(), &files);
2811        assert_eq!(g.stats.unresolved_external_count, 1);
2812    }
2813
2814    // --- namespace-proximity (step 3, exact match, not a walk) ------------
2815
2816    #[test]
2817    fn same_namespace_reference_resolves_without_any_using() {
2818        let files = vec![
2819            (
2820                "A/Widget.cs".to_string(),
2821                frag(
2822                    vec![def("A.Widget", "Widget", "A", "class")],
2823                    vec![],
2824                    vec![],
2825                ),
2826            ),
2827            (
2828                "A/Holder.cs".to_string(),
2829                frag(
2830                    vec![def("A.Holder", "Holder", "A", "class")],
2831                    vec![],
2832                    vec![type_ref("uses-type", "Widget", None, "A")],
2833                ),
2834            ),
2835        ];
2836        let g = resolve_graph(&no_git_root(), &files);
2837        assert_eq!(g.stats.ambiguous_count, 0);
2838        assert_eq!(g.stats.unresolved_external_count, 0);
2839        assert_eq!(g.stats.edges_by_kind.uses_type, 1);
2840    }
2841
2842    #[test]
2843    fn empty_namespace_is_treated_as_absent_not_as_a_matchable_prefix() {
2844        // A file-scope (no enclosing namespace) reference to another
2845        // file-scope type must resolve via step 4 (global uniqueness), NOT
2846        // step 3 -- an empty-string ns must behave as absent, not as a
2847        // matchable prefix.
2848        let files = vec![
2849            (
2850                "Root.cs".to_string(),
2851                frag(vec![def("Anchor", "Anchor", "", "class")], vec![], vec![]),
2852            ),
2853            (
2854                "Probe.cs".to_string(),
2855                frag(
2856                    vec![def("Probe", "Probe", "", "class")],
2857                    vec![],
2858                    vec![type_ref("uses-type", "Anchor", None, "")],
2859                ),
2860            ),
2861        ];
2862        let g = resolve_graph(&no_git_root(), &files);
2863        assert_eq!(g.stats.ambiguous_count, 0);
2864        assert_eq!(g.stats.edges_by_kind.uses_type, 1);
2865    }
2866
2867    // --- global using -----------------------------------------------------
2868
2869    #[test]
2870    fn global_using_resolves_a_bare_name_from_an_unrelated_namespace() {
2871        let files = vec![
2872            (
2873                "Catalog/Status.cs".to_string(),
2874                frag(
2875                    vec![def("Catalog.Status", "Status", "Catalog", "enum")],
2876                    vec![],
2877                    vec![],
2878                ),
2879            ),
2880            (
2881                "Globals.cs".to_string(),
2882                frag(
2883                    vec![],
2884                    vec![FragUsing::Plain {
2885                        text: "Catalog".into(),
2886                        global: true,
2887                    }],
2888                    vec![],
2889                ),
2890            ),
2891            (
2892                "Ops/View.cs".to_string(),
2893                frag(
2894                    vec![def("Ops.View", "View", "Ops", "class")],
2895                    vec![],
2896                    vec![type_ref("uses-type", "Status", None, "Ops")],
2897                ),
2898            ),
2899        ];
2900        let g = resolve_graph(&no_git_root(), &files);
2901        let edge = find_edge(&g, |e| matches!(e, Edge::UsesType { .. }))
2902            .expect("resolved via global using");
2903        match edge {
2904            Edge::UsesType { to, .. } => assert_eq!(to, "Catalog.Status"),
2905            _ => unreachable!(),
2906        }
2907    }
2908
2909    // --- tier (a) widened: static property / field ------------------------
2910
2911    // The uses-member edge set is split in two. Almost every assertion in this
2912    // module is about the PRECISE half, so the two default accessors below
2913    // filter to it and the `heuristic_*` counterparts are what a heuristic-tier
2914    // test reaches for -- rather than filtering `heuristic` inline forty times.
2915    fn member_edge_targets(g: &Graph) -> Vec<&str> {
2916        g.edges
2917            .iter()
2918            .filter_map(|e| match e {
2919                Edge::UsesMember {
2920                    to,
2921                    heuristic: false,
2922                    ..
2923                } => Some(to.as_str()),
2924                _ => None,
2925            })
2926            .collect()
2927    }
2928
2929    fn heuristic_member_edge_targets(g: &Graph) -> Vec<&str> {
2930        g.edges
2931            .iter()
2932            .filter_map(|e| match e {
2933                Edge::UsesMember {
2934                    to,
2935                    heuristic: true,
2936                    ..
2937                } => Some(to.as_str()),
2938                _ => None,
2939            })
2940            .collect()
2941    }
2942
2943    #[test]
2944    fn stage2_tier_a_static_property_access_on_a_bare_qualifier_now_emits() {
2945        // The MessageUrn.Prefix shape: same namespace as the def, so the
2946        // qualifier answers at the namespace ladder step -- no using, no
2947        // type-argument list, no dotted qualifier. Such a bare qualifier
2948        // carries no certainty signal on its own.
2949        let files = vec![
2950            (
2951                "Other/MessageUrn.cs".to_string(),
2952                frag(
2953                    vec![def_with(
2954                        "App.Consumers.MessageUrn",
2955                        "MessageUrn",
2956                        "App.Consumers",
2957                        "class",
2958                        &[],
2959                        &["Prefix"],
2960                        &[],
2961                    )],
2962                    vec![],
2963                    vec![],
2964                ),
2965            ),
2966            (
2967                "Consumers/UsesProperty.cs".to_string(),
2968                frag(
2969                    vec![def(
2970                        "App.Consumers.UsesProperty",
2971                        "UsesProperty",
2972                        "App.Consumers",
2973                        "class",
2974                    )],
2975                    vec![],
2976                    vec![
2977                        member_ref("MessageUrn", None, "Prefix", "App.Consumers"),
2978                        member_ref("MessageUrn", None, "NotDeclared", "App.Consumers"),
2979                    ],
2980                ),
2981            ),
2982        ];
2983        let g = resolve_graph(&no_git_root(), &files);
2984        assert_eq!(
2985            member_edge_targets(&g),
2986            vec!["App.Consumers.MessageUrn"],
2987            "the declared property emits; the undeclared member still does not"
2988        );
2989    }
2990
2991    #[test]
2992    fn stage2_tier_a_const_field_access_on_a_bare_qualifier_emits() {
2993        let files = vec![
2994            (
2995                "Other/Limits.cs".to_string(),
2996                frag(
2997                    vec![def_with(
2998                        "App.Other.Limits",
2999                        "Limits",
3000                        "App.Other",
3001                        "class",
3002                        &[],
3003                        &[],
3004                        &["MaxRetries"],
3005                    )],
3006                    vec![],
3007                    vec![],
3008                ),
3009            ),
3010            (
3011                "Consumers/UsesField.cs".to_string(),
3012                frag(
3013                    vec![def(
3014                        "App.Consumers.UsesField",
3015                        "UsesField",
3016                        "App.Consumers",
3017                        "class",
3018                    )],
3019                    vec![FragUsing::Plain {
3020                        text: "App.Other".into(),
3021                        global: false,
3022                    }],
3023                    vec![member_ref("Limits", None, "MaxRetries", "App.Consumers")],
3024                ),
3025            ),
3026        ];
3027        let g = resolve_graph(&no_git_root(), &files);
3028        assert_eq!(member_edge_targets(&g), vec!["App.Other.Limits"]);
3029    }
3030
3031    #[test]
3032    fn stage2_tier_a_partial_class_contributes_its_own_properties_and_fields() {
3033        let files = vec![
3034            (
3035                "Other/Config.Part1.cs".to_string(),
3036                frag(
3037                    vec![def_with(
3038                        "App.Other.Config",
3039                        "Config",
3040                        "App.Other",
3041                        "class",
3042                        &[],
3043                        &[],
3044                        &["Retries"],
3045                    )],
3046                    vec![],
3047                    vec![],
3048                ),
3049            ),
3050            (
3051                "Other/Config.Part2.cs".to_string(),
3052                frag(
3053                    vec![def_with(
3054                        "App.Other.Config",
3055                        "Config",
3056                        "App.Other",
3057                        "class",
3058                        &[],
3059                        &["Name"],
3060                        &[],
3061                    )],
3062                    vec![],
3063                    vec![],
3064                ),
3065            ),
3066            (
3067                "Consumers/UsesBoth.cs".to_string(),
3068                frag(
3069                    vec![def(
3070                        "App.Consumers.UsesBoth",
3071                        "UsesBoth",
3072                        "App.Consumers",
3073                        "class",
3074                    )],
3075                    vec![FragUsing::Plain {
3076                        text: "App.Other".into(),
3077                        global: false,
3078                    }],
3079                    vec![
3080                        member_ref("Config", None, "Retries", "App.Consumers"),
3081                        member_ref("Config", None, "Name", "App.Consumers"),
3082                    ],
3083                ),
3084            ),
3085        ];
3086        let g = resolve_graph(&no_git_root(), &files);
3087        assert_eq!(
3088            member_edge_targets(&g),
3089            vec!["App.Other.Config", "App.Other.Config"],
3090            "both halves of the partial class vouch for their own member"
3091        );
3092    }
3093
3094    // --- tier (e): instance receivers ---
3095
3096    #[test]
3097    fn stage2_tier_e_a_declared_local_receiver_resolves_through_the_ladder() {
3098        let files = vec![
3099            (
3100                "Other/Widget.cs".to_string(),
3101                frag(
3102                    vec![def_with(
3103                        "App.Other.Widget",
3104                        "Widget",
3105                        "App.Other",
3106                        "class",
3107                        &["Render"],
3108                        &[],
3109                        &[],
3110                    )],
3111                    vec![],
3112                    vec![],
3113                ),
3114            ),
3115            (
3116                "Consumers/LocalReceiver.cs".to_string(),
3117                frag(
3118                    vec![def(
3119                        "App.Consumers.LocalReceiver",
3120                        "LocalReceiver",
3121                        "App.Consumers",
3122                        "class",
3123                    )],
3124                    vec![FragUsing::Plain {
3125                        text: "App.Other".into(),
3126                        global: false,
3127                    }],
3128                    vec![receiver_ref(
3129                        "w",
3130                        "Render",
3131                        "App.Consumers",
3132                        "Widget",
3133                        Some(0),
3134                    )],
3135                ),
3136            ),
3137        ];
3138        let g = resolve_graph(&no_git_root(), &files);
3139        let edge = find_edge(&g, |e| matches!(e, Edge::UsesMember { .. }))
3140            .expect("uses-member edge present");
3141        match edge {
3142            Edge::UsesMember { to, to_file, .. } => {
3143                assert_eq!(to, "App.Other.Widget");
3144                assert_eq!(to_file, "Other/Widget.cs");
3145            }
3146            _ => unreachable!(),
3147        }
3148        assert_eq!(g.stats.edges_by_kind.uses_member, 1);
3149    }
3150
3151    #[test]
3152    fn stage2_tier_e_a_receiver_whose_member_lives_in_the_property_list_also_emits() {
3153        // Tier (e) reuses the SAME widened membership test as tier (a) --
3154        // methods ∪ properties ∪ fields, not methods alone.
3155        let files = vec![
3156            (
3157                "Other/Widget.cs".to_string(),
3158                frag(
3159                    vec![def_with(
3160                        "App.Other.Widget",
3161                        "Widget",
3162                        "App.Other",
3163                        "class",
3164                        &[],
3165                        &["Name"],
3166                        &[],
3167                    )],
3168                    vec![],
3169                    vec![],
3170                ),
3171            ),
3172            (
3173                "Consumers/PropReceiver.cs".to_string(),
3174                frag(
3175                    vec![def(
3176                        "App.Consumers.PropReceiver",
3177                        "PropReceiver",
3178                        "App.Consumers",
3179                        "class",
3180                    )],
3181                    vec![FragUsing::Plain {
3182                        text: "App.Other".into(),
3183                        global: false,
3184                    }],
3185                    vec![receiver_ref("w", "Name", "App.Consumers", "Widget", None)],
3186                ),
3187            ),
3188        ];
3189        let g = resolve_graph(&no_git_root(), &files);
3190        assert_eq!(member_edge_targets(&g), vec!["App.Other.Widget"]);
3191    }
3192
3193    #[test]
3194    fn stage2_tier_e_a_receiver_type_reached_only_through_a_type_alias_resolves_too() {
3195        let files = vec![
3196            (
3197                "One/Item.cs".to_string(),
3198                frag(
3199                    vec![def_with(
3200                        "App.One.Item",
3201                        "Item",
3202                        "App.One",
3203                        "class",
3204                        &["Go"],
3205                        &[],
3206                        &[],
3207                    )],
3208                    vec![],
3209                    vec![],
3210                ),
3211            ),
3212            (
3213                "Two/Item.cs".to_string(),
3214                frag(
3215                    vec![def_with(
3216                        "App.Two.Item",
3217                        "Item",
3218                        "App.Two",
3219                        "class",
3220                        &["Go"],
3221                        &[],
3222                        &[],
3223                    )],
3224                    vec![],
3225                    vec![],
3226                ),
3227            ),
3228            (
3229                "Consumers/AliasReceiver.cs".to_string(),
3230                frag(
3231                    vec![def(
3232                        "App.Consumers.AliasReceiver",
3233                        "AliasReceiver",
3234                        "App.Consumers",
3235                        "class",
3236                    )],
3237                    vec![FragUsing::Alias {
3238                        alias: "AliasedItem".into(),
3239                        target: "App.Two.Item".into(),
3240                        global: false,
3241                    }],
3242                    vec![receiver_ref(
3243                        "item",
3244                        "Go",
3245                        "App.Consumers",
3246                        "AliasedItem",
3247                        Some(0),
3248                    )],
3249                ),
3250            ),
3251        ];
3252        let g = resolve_graph(&no_git_root(), &files);
3253        assert_eq!(
3254            member_edge_targets(&g),
3255            vec!["App.Two.Item"],
3256            "the alias pins the receiver type even though the simple name \"Item\" is ambiguous"
3257        );
3258    }
3259
3260    #[test]
3261    fn stage2_tier_e_a_receiver_whose_type_does_not_declare_the_member_earns_no_edge() {
3262        let files = vec![
3263            (
3264                "Other/Widget.cs".to_string(),
3265                frag(
3266                    vec![def_with(
3267                        "App.Other.Widget",
3268                        "Widget",
3269                        "App.Other",
3270                        "class",
3271                        &["Render"],
3272                        &[],
3273                        &[],
3274                    )],
3275                    vec![],
3276                    vec![],
3277                ),
3278            ),
3279            (
3280                "Consumers/UnknownMember.cs".to_string(),
3281                frag(
3282                    vec![def(
3283                        "App.Consumers.UnknownMember",
3284                        "UnknownMember",
3285                        "App.Consumers",
3286                        "class",
3287                    )],
3288                    vec![FragUsing::Plain {
3289                        text: "App.Other".into(),
3290                        global: false,
3291                    }],
3292                    vec![receiver_ref(
3293                        "w",
3294                        "Explode",
3295                        "App.Consumers",
3296                        "Widget",
3297                        Some(0),
3298                    )],
3299                ),
3300            ),
3301        ];
3302        let g = resolve_graph(&no_git_root(), &files);
3303        assert!(
3304            member_edge_targets(&g).is_empty(),
3305            "stage 3/4 territory, not an edge"
3306        );
3307    }
3308
3309    #[test]
3310    fn stage2_tier_e_an_ambiguous_receiver_type_earns_no_edge_and_no_ambiguous_noise() {
3311        let files = vec![
3312            (
3313                "One/Handler.cs".to_string(),
3314                frag(
3315                    vec![def_with(
3316                        "App.One.Handler",
3317                        "Handler",
3318                        "App.One",
3319                        "class",
3320                        &["Go"],
3321                        &[],
3322                        &[],
3323                    )],
3324                    vec![],
3325                    vec![],
3326                ),
3327            ),
3328            (
3329                "Two/Handler.cs".to_string(),
3330                frag(
3331                    vec![def_with(
3332                        "App.Two.Handler",
3333                        "Handler",
3334                        "App.Two",
3335                        "class",
3336                        &["Go"],
3337                        &[],
3338                        &[],
3339                    )],
3340                    vec![],
3341                    vec![],
3342                ),
3343            ),
3344            (
3345                "Consumers/AmbiguousReceiver.cs".to_string(),
3346                frag(
3347                    vec![def(
3348                        "App.Consumers.AmbiguousReceiver",
3349                        "AmbiguousReceiver",
3350                        "App.Consumers",
3351                        "class",
3352                    )],
3353                    vec![],
3354                    vec![receiver_ref("h", "Go", "App.Consumers", "Handler", Some(0))],
3355                ),
3356            ),
3357        ];
3358        let g = resolve_graph(&no_git_root(), &files);
3359        assert!(
3360            member_edge_targets(&g).is_empty(),
3361            "never pick a winner between two same-named receiver types"
3362        );
3363        assert_eq!(
3364            g.stats.ambiguous_count, 0,
3365            "a uses-member miss is still never reported as ambiguous"
3366        );
3367        assert_eq!(g.stats.unresolved_external_count, 0);
3368        // Refusing to PICK is not the same as having nothing to say. Both
3369        // candidates declare Go, so both are named as guesses -- the
3370        // strong scored case, where the right answer is provably one of the
3371        // two. Neither is same-namespace and the file has no usings, so both
3372        // score 1 and the def id breaks the tie.
3373        assert_eq!(
3374            heuristic_member_edge_targets(&g),
3375            vec!["App.One.Handler", "App.Two.Handler"]
3376        );
3377        assert_eq!(g.stats.heuristic_edge_count, 2);
3378    }
3379
3380    #[test]
3381    fn stage2_tier_e_an_unresolvable_receiver_type_earns_no_edge() {
3382        let files = vec![(
3383            "Consumers/ExternalReceiver.cs".to_string(),
3384            frag(
3385                vec![def(
3386                    "App.Consumers.ExternalReceiver",
3387                    "ExternalReceiver",
3388                    "App.Consumers",
3389                    "class",
3390                )],
3391                vec![],
3392                vec![receiver_ref(
3393                    "s",
3394                    "Trim",
3395                    "App.Consumers",
3396                    "StringBuilder",
3397                    Some(0),
3398                )],
3399            ),
3400        )];
3401        let g = resolve_graph(&no_git_root(), &files);
3402        assert!(member_edge_targets(&g).is_empty());
3403        assert_eq!(
3404            g.stats.unresolved_external_count, 0,
3405            "a uses-member miss is never counted as external either"
3406        );
3407    }
3408
3409    #[test]
3410    fn stage2_tier_e_a_ref_with_no_fact_at_all_is_untouched_by_the_new_tier() {
3411        // The extraction-side negatives (predefined-type receiver, conflicting
3412        // duplicate locals, var-from-call) all arrive here as the SAME thing:
3413        // a member ref with no receiverType. One resolver-side pin covers the
3414        // whole family -- their extraction-side halves are pinned in
3415        // extract.rs's own stage2b tests.
3416        let files = vec![
3417            (
3418                "Other/Widget.cs".to_string(),
3419                frag(
3420                    vec![def_with(
3421                        "App.Other.Widget",
3422                        "Widget",
3423                        "App.Other",
3424                        "class",
3425                        &["Render"],
3426                        &[],
3427                        &[],
3428                    )],
3429                    vec![],
3430                    vec![],
3431                ),
3432            ),
3433            (
3434                "Consumers/NoFact.cs".to_string(),
3435                frag(
3436                    vec![def(
3437                        "App.Consumers.NoFact",
3438                        "NoFact",
3439                        "App.Consumers",
3440                        "class",
3441                    )],
3442                    vec![FragUsing::Plain {
3443                        text: "App.Other".into(),
3444                        global: false,
3445                    }],
3446                    vec![member_ref("widget", None, "Render", "App.Consumers")],
3447                ),
3448            ),
3449        ];
3450        let g = resolve_graph(&no_git_root(), &files);
3451        assert!(
3452            member_edge_targets(&g).is_empty(),
3453            "no fact was recorded, so there is nothing to resolve"
3454        );
3455        // With no fact of any kind the qualifier `widget` resolves to nothing
3456        // at all, which is the only door into the scored
3457        // tier's uniqueness fallback -- and Widget is the one def graph-wide
3458        // declaring `Render`, so it is named as a GUESS. Pinned deliberately:
3459        // this is the weakest evidence the resolver acts on, it is exactly why
3460        // the fallback is capped and tagged rather than emitted as fact, and it
3461        // must never leak into the precise set above.
3462        assert_eq!(heuristic_member_edge_targets(&g), vec!["App.Other.Widget"]);
3463    }
3464
3465    #[test]
3466    fn stage2_tier_e_shadowing_is_settled_at_extraction_so_the_edge_follows_the_recorded_fact() {
3467        let files = vec![
3468            (
3469                "Other/Widget.cs".to_string(),
3470                frag(
3471                    vec![def_with(
3472                        "App.Other.Widget",
3473                        "Widget",
3474                        "App.Other",
3475                        "class",
3476                        &["Go"],
3477                        &[],
3478                        &[],
3479                    )],
3480                    vec![],
3481                    vec![],
3482                ),
3483            ),
3484            (
3485                "Other/Gadget.cs".to_string(),
3486                frag(
3487                    vec![def_with(
3488                        "App.Other.Gadget",
3489                        "Gadget",
3490                        "App.Other",
3491                        "class",
3492                        &["Go"],
3493                        &[],
3494                        &[],
3495                    )],
3496                    vec![],
3497                    vec![],
3498                ),
3499            ),
3500            (
3501                "Consumers/ShadowReceiver.cs".to_string(),
3502                frag(
3503                    vec![def(
3504                        "App.Consumers.ShadowReceiver",
3505                        "ShadowReceiver",
3506                        "App.Consumers",
3507                        "class",
3508                    )],
3509                    vec![FragUsing::Plain {
3510                        text: "App.Other".into(),
3511                        global: false,
3512                    }],
3513                    // The parameter shadows the same-named field, so the
3514                    // extractor recorded Gadget (see extract.rs's own test).
3515                    vec![receiver_ref(
3516                        "handler",
3517                        "Go",
3518                        "App.Consumers",
3519                        "Gadget",
3520                        Some(0),
3521                    )],
3522                ),
3523            ),
3524        ];
3525        let g = resolve_graph(&no_git_root(), &files);
3526        assert_eq!(
3527            member_edge_targets(&g),
3528            vec!["App.Other.Gadget"],
3529            "the innermost declaration wins"
3530        );
3531    }
3532
3533    #[test]
3534    fn stage2_tier_e_never_adds_a_second_edge_for_a_ref_an_earlier_tier_already_claimed() {
3535        // `public void Run(Widget Widget) => Widget.Render();` -- the
3536        // qualifier resolves as a TYPE (tier (a)) AND carries a receiver fact.
3537        let files = vec![
3538            (
3539                "Other/Widget.cs".to_string(),
3540                frag(
3541                    vec![def_with(
3542                        "App.Other.Widget",
3543                        "Widget",
3544                        "App.Other",
3545                        "class",
3546                        &["Render"],
3547                        &[],
3548                        &[],
3549                    )],
3550                    vec![],
3551                    vec![],
3552                ),
3553            ),
3554            (
3555                "Consumers/OneEdge.cs".to_string(),
3556                frag(
3557                    vec![def(
3558                        "App.Consumers.OneEdge",
3559                        "OneEdge",
3560                        "App.Consumers",
3561                        "class",
3562                    )],
3563                    vec![FragUsing::Plain {
3564                        text: "App.Other".into(),
3565                        global: false,
3566                    }],
3567                    vec![receiver_ref(
3568                        "Widget",
3569                        "Render",
3570                        "App.Consumers",
3571                        "Widget",
3572                        Some(0),
3573                    )],
3574                ),
3575            ),
3576        ];
3577        let g = resolve_graph(&no_git_root(), &files);
3578        assert_eq!(
3579            g.stats.edges_by_kind.uses_member, 1,
3580            "still exactly one edge"
3581        );
3582        assert_eq!(member_edge_targets(&g), vec!["App.Other.Widget"]);
3583    }
3584
3585    #[test]
3586    fn stage2_tier_e_never_fires_for_a_dotted_chain_tail_because_it_carries_no_fact() {
3587        // Chain-tail regression, resolver half: "w.Inner.Tail()" flattens to a
3588        // DOTTED qualifier, which the extractor's bare-only guard refuses a
3589        // fact for. Even though Widget declares Tail, no edge is earned here.
3590        let files = vec![
3591            (
3592                "Other/Widget.cs".to_string(),
3593                frag(
3594                    vec![def_with(
3595                        "App.Other.Widget",
3596                        "Widget",
3597                        "App.Other",
3598                        "class",
3599                        &["Tail"],
3600                        &["Inner"],
3601                        &[],
3602                    )],
3603                    vec![],
3604                    vec![],
3605                ),
3606            ),
3607            (
3608                "Consumers/Chain.cs".to_string(),
3609                frag(
3610                    vec![def(
3611                        "App.Consumers.Chain",
3612                        "Chain",
3613                        "App.Consumers",
3614                        "class",
3615                    )],
3616                    vec![FragUsing::Plain {
3617                        text: "App.Other".into(),
3618                        global: false,
3619                    }],
3620                    vec![
3621                        receiver_ref("w", "Inner", "App.Consumers", "Widget", None),
3622                        member_ref("Inner", Some("w.Inner"), "Tail", "App.Consumers"),
3623                    ],
3624                ),
3625            ),
3626        ];
3627        let g = resolve_graph(&no_git_root(), &files);
3628        assert_eq!(
3629            member_edge_targets(&g),
3630            vec!["App.Other.Widget"],
3631            "only the head access earns a PRECISE edge"
3632        );
3633        // The tail's dotted qualifier ("w.Inner") resolves to nothing at all,
3634        // so the uniqueness fallback reaches it and -- Widget
3635        // being the only def declaring `Tail` -- names Widget as a GUESS. That
3636        // is the opposite of the bug this test pins: the tail may never inherit
3637        // the head's fact and emit a PRECISE edge, but it is allowed to be
3638        // guessed at by name, tagged, from far weaker evidence.
3639        assert_eq!(heuristic_member_edge_targets(&g), vec!["App.Other.Widget"]);
3640    }
3641
3642    // --- tier (e) end-to-end: real C# through extract -> resolve ---
3643    //
3644    // The tier tests above hand-build fragments, which pins the RESOLVER in
3645    // isolation but takes the extractor's word for what it records. These four
3646    // run real fixtures through this crate's own extractor, so a fact that never
3647    // gets recorded (or gets recorded on the wrong line) fails here rather than
3648    // passing vacuously.
3649
3650    fn fragments_for(files: &[(&str, &str)]) -> Vec<(String, Fragment)> {
3651        files
3652            .iter()
3653            .map(|(rel, src)| {
3654                (
3655                    (*rel).to_string(),
3656                    crate::graph::fragment_from_extraction(&crate::extract::extract(src)),
3657                )
3658            })
3659            .collect()
3660    }
3661
3662    fn member_edges_from<'a>(g: &'a Graph, from: &str) -> Vec<(&'a str, usize)> {
3663        g.edges
3664            .iter()
3665            .filter_map(|e| match e {
3666                Edge::UsesMember {
3667                    from_file,
3668                    from_line,
3669                    to,
3670                    heuristic: false,
3671                    ..
3672                } if from_file == from => Some((to.as_str(), *from_line)),
3673                _ => None,
3674            })
3675            .collect()
3676    }
3677
3678    /// The guessed half of the same file's uses-member edges, in emission order
3679    /// (which for the scored tier IS scored order). Resolved TYPE-reference
3680    /// targets out of one file, in edge order -- the ladder-walk tests assert on
3681    /// these the way the member tests assert on `member_edges_from`.
3682    fn type_edge_targets_from<'a>(g: &'a Graph, from: &str) -> Vec<&'a str> {
3683        g.edges
3684            .iter()
3685            .filter_map(|e| match e {
3686                Edge::UsesType { from_file, to, .. } if from_file == from => Some(to.as_str()),
3687                _ => None,
3688            })
3689            .collect()
3690    }
3691
3692    fn heuristic_member_edges_from<'a>(g: &'a Graph, from: &str) -> Vec<(&'a str, usize)> {
3693        g.edges
3694            .iter()
3695            .filter_map(|e| match e {
3696                Edge::UsesMember {
3697                    from_file,
3698                    from_line,
3699                    to,
3700                    heuristic: true,
3701                    ..
3702                } if from_file == from => Some((to.as_str(), *from_line)),
3703                _ => None,
3704            })
3705            .collect()
3706    }
3707
3708    #[test]
3709    fn stage2_end_to_end_static_property_access_emits_and_an_undeclared_member_does_not() {
3710        let files = fragments_for(&[
3711            (
3712                "Other/MessageUrn.cs",
3713                "namespace App.Consumers { public static class MessageUrn { public static string Prefix { get; } } }",
3714            ),
3715            (
3716                "Consumers/UsesProperty.cs",
3717                "\nnamespace App.Consumers;\n\npublic class UsesProperty\n{\n  public object Get() => MessageUrn.Prefix;\n  public object Miss() => MessageUrn.NotDeclared;\n}\n",
3718            ),
3719        ]);
3720        let g = resolve_graph(&no_git_root(), &files);
3721        assert_eq!(
3722            member_edges_from(&g, "Consumers/UsesProperty.cs"),
3723            vec![("App.Consumers.MessageUrn", 6)],
3724            "the declared property emits at its own line; the undeclared member does not"
3725        );
3726    }
3727
3728    #[test]
3729    fn stage2_end_to_end_a_declared_local_receiver_earns_an_edge_at_the_access_line() {
3730        let files = fragments_for(&[
3731            ("Other/Widget.cs", "namespace App.Other { public class Widget { public void Render() { } } }"),
3732            (
3733                "Consumers/LocalReceiver.cs",
3734                "\nusing App.Other;\n\nnamespace App.Consumers;\n\npublic class LocalReceiver\n{\n  public void Run()\n  {\n    Widget w = new Widget();\n    w.Render();\n  }\n}\n",
3735            ),
3736        ]);
3737        let g = resolve_graph(&no_git_root(), &files);
3738        assert_eq!(
3739            member_edges_from(&g, "Consumers/LocalReceiver.cs"),
3740            vec![("App.Other.Widget", 11)]
3741        );
3742    }
3743
3744    #[test]
3745    fn stage2_end_to_end_a_class_field_receiver_earns_an_edge_the_ctor_injection_shape() {
3746        let files = fragments_for(&[
3747            ("Other/IRepo.cs", "namespace App.Other { public interface IRepo { void Save(); } }"),
3748            (
3749                "Consumers/FieldReceiver.cs",
3750                "\nusing App.Other;\n\nnamespace App.Consumers;\n\npublic class FieldReceiver\n{\n  private readonly IRepo _repo;\n\n  public FieldReceiver(IRepo repo) { _repo = repo; }\n\n  public void Run() { _repo.Save(); }\n}\n",
3751            ),
3752        ]);
3753        let g = resolve_graph(&no_git_root(), &files);
3754        assert_eq!(
3755            member_edges_from(&g, "Consumers/FieldReceiver.cs"),
3756            vec![("App.Other.IRepo", 12)],
3757            "the field access in Run, not the constructor assignment"
3758        );
3759    }
3760
3761    #[test]
3762    fn stage2_end_to_end_a_chain_tail_earns_no_edge_from_a_fact_it_did_not_inherit() {
3763        // Widget declares BOTH Inner and Tail, so if the flattened tail
3764        // ("w.Inner") had inherited the head's receiverType it would have
3765        // produced a second, wrong edge. Stage-1 chain-tail regression class.
3766        let files = fragments_for(&[
3767            (
3768                "Other/Widget.cs",
3769                "namespace App.Other { public class Widget { public object Inner { get; } public void Tail() { } } }",
3770            ),
3771            (
3772                "Consumers/Chain.cs",
3773                "using App.Other;\n\nnamespace App.Consumers;\n\npublic class Chain\n{\n  public void Run()\n  {\n    Widget w = new Widget();\n    w.Inner.Tail();\n  }\n}\n",
3774            ),
3775        ]);
3776        let g = resolve_graph(&no_git_root(), &files);
3777        assert_eq!(
3778            member_edges_from(&g, "Consumers/Chain.cs"),
3779            vec![("App.Other.Widget", 10)],
3780            "only the head access (\"w.Inner\") earns a PRECISE edge, and it is the head's line, not the tail's"
3781        );
3782        // End-to-end half of the same split: the tail is guessed at by
3783        // member-name uniqueness, tagged, on the same line.
3784        assert_eq!(
3785            heuristic_member_edges_from(&g, "Consumers/Chain.cs"),
3786            vec![("App.Other.Widget", 10)],
3787            "exactly one guess -- the tail; the head already has its fact and is never second-guessed"
3788        );
3789    }
3790
3791    // --- tier (f): extension methods ---
3792    //
3793    // Real fixtures run through this crate's own extractor, so an extension fact
3794    // that never gets recorded fails here rather than passing vacuously.
3795
3796    const WIDGET_SRC: (&str, &str) = (
3797        "Other/Widget.cs",
3798        "namespace App.Other { public class Widget { } }",
3799    );
3800    const WIDGET_EXTENSIONS_SRC: (&str, &str) = (
3801        "Ext/WidgetExtensions.cs",
3802        "namespace App.Ext { public static class WidgetExtensions { public static void Render(this Widget w) { } } }",
3803    );
3804
3805    #[test]
3806    fn stage3_tier_f_an_extension_call_resolves_to_the_static_class_when_its_namespace_is_imported()
3807    {
3808        let files = fragments_for(&[
3809            WIDGET_SRC,
3810            WIDGET_EXTENSIONS_SRC,
3811            (
3812                "Consumers/UsesExtension.cs",
3813                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class UsesExtension\n{\n  public void Run(Widget w) => w.Render();\n}\n",
3814            ),
3815        ]);
3816        let g = resolve_graph(&no_git_root(), &files);
3817        assert_eq!(
3818            heuristic_member_edges_from(&g, "Consumers/UsesExtension.cs"),
3819            vec![("App.Ext.WidgetExtensions", 9)],
3820            "Widget does not declare Render -- only the extension tier can claim this call, and the edge targets the DECLARING static class"
3821        );
3822        let edge = find_edge(&g, |e| matches!(e, Edge::UsesMember { .. }))
3823            .expect("uses-member edge present");
3824        match edge {
3825            // Tier (f) is a HEURISTIC tier: it emits exactly this one edge, and
3826            // the edge declares itself a guess, because the instance-member veto
3827            // that would disprove it cannot see members of an out-of-graph
3828            // receiver and never will without a build.
3829            Edge::UsesMember {
3830                to_file, heuristic, ..
3831            } => {
3832                assert_eq!(to_file, "Ext/WidgetExtensions.cs");
3833                assert!(*heuristic, "tier (f) emits heuristic edges");
3834            }
3835            _ => unreachable!(),
3836        }
3837        assert_eq!(
3838            serde_json::to_string(edge).unwrap(),
3839            r#"{"kind":"uses-member","from_file":"Consumers/UsesExtension.cs","from_line":9,"to":"App.Ext.WidgetExtensions","to_file":"Ext/WidgetExtensions.cs","heuristic":true}"#,
3840            "heuristic is appended LAST -- the exact field order Node's own byte assertion pins"
3841        );
3842        assert_eq!(
3843            g.stats.edges_by_kind.uses_member, 0,
3844            "edges_by_kind counts PRECISE edges only, so a heuristic tier cannot inflate it"
3845        );
3846        assert_eq!(
3847            g.stats.heuristic_edge_count, 1,
3848            "guesses are counted in their own stat instead"
3849        );
3850    }
3851
3852    #[test]
3853    fn stage3_tier_f_an_extension_class_in_the_refs_own_namespace_is_admitted_with_no_using_at_all()
3854    {
3855        let files = fragments_for(&[
3856            WIDGET_SRC,
3857            (
3858                "Consumers/WidgetExtensions.cs",
3859                "namespace App.Consumers { public static class WidgetExtensions { public static void Render(this Widget w) { } } }",
3860            ),
3861            (
3862                "Consumers/SameNamespace.cs",
3863                "\nusing App.Other;\n\nnamespace App.Consumers;\n\npublic class SameNamespace\n{\n  public void Run(Widget w) => w.Render();\n}\n",
3864            ),
3865        ]);
3866        let g = resolve_graph(&no_git_root(), &files);
3867        assert_eq!(
3868            heuristic_member_edges_from(&g, "Consumers/SameNamespace.cs"),
3869            vec![("App.Consumers.WidgetExtensions", 8)]
3870        );
3871        assert!(
3872            member_edges_from(&g, "Consumers/SameNamespace.cs").is_empty(),
3873            "admission by own-namespace is still tier (f), so still a guess"
3874        );
3875    }
3876
3877    #[test]
3878    fn stage3_tier_f_an_extension_class_whose_namespace_is_not_imported_earns_no_edge() {
3879        // Deliberately no `using App.Ext;` -- in real C# this file would not
3880        // compile, and the resolver must not paper over that with a name match.
3881        let files = fragments_for(&[
3882            WIDGET_SRC,
3883            WIDGET_EXTENSIONS_SRC,
3884            (
3885                "Consumers/NoUsing.cs",
3886                "\nusing App.Other;\n\nnamespace App.Consumers;\n\npublic class NoUsing\n{\n  public void Run(Widget w) => w.Render();\n}\n",
3887            ),
3888        ]);
3889        let g = resolve_graph(&no_git_root(), &files);
3890        assert!(
3891            member_edges_from(&g, "Consumers/NoUsing.cs").is_empty(),
3892            "visibility is the admission rule -- an unimported extension class is not a candidate"
3893        );
3894        assert!(
3895            !g.edges
3896                .iter()
3897                .any(|e| matches!(e, Edge::Ambiguous { origin, .. } if origin == "uses-member")),
3898            "a declined extension lookup is still never ambiguous noise"
3899        );
3900    }
3901
3902    #[test]
3903    fn stage3_tier_f_two_admitted_candidates_earn_no_edge_and_no_ambiguous_increment() {
3904        let files = fragments_for(&[
3905            WIDGET_SRC,
3906            (
3907                "ExtA/AExtensions.cs",
3908                "namespace App.ExtA { public static class AExtensions { public static void Render(this Widget w) { } } }",
3909            ),
3910            (
3911                "ExtB/BExtensions.cs",
3912                "namespace App.ExtB { public static class BExtensions { public static void Render(this Widget w) { } } }",
3913            ),
3914            (
3915                "Consumers/TwoCandidates.cs",
3916                "\nusing App.Other;\nusing App.ExtA;\nusing App.ExtB;\n\nnamespace App.Consumers;\n\npublic class TwoCandidates\n{\n  public void Run(Widget w) => w.Render();\n}\n",
3917            ),
3918        ]);
3919        let g = resolve_graph(&no_git_root(), &files);
3920        assert!(
3921            member_edges_from(&g, "Consumers/TwoCandidates.cs").is_empty(),
3922            "never pick a winner between two visible extension classes"
3923        );
3924        assert_eq!(
3925            g.stats.ambiguous_count, 0,
3926            "a refused extension lookup does not touch the ambiguous stats"
3927        );
3928    }
3929
3930    #[test]
3931    fn stage3_tier_f_an_extension_whose_this_type_differs_from_the_receiver_type_earns_no_edge() {
3932        let files = fragments_for(&[
3933            WIDGET_SRC,
3934            ("Other/Gadget.cs", "namespace App.Other { public class Gadget { } }"),
3935            (
3936                "Ext/GadgetExtensions.cs",
3937                "namespace App.Ext { public static class GadgetExtensions { public static void Render(this Gadget g) { } } }",
3938            ),
3939            (
3940                "Consumers/WrongReceiver.cs",
3941                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class WrongReceiver\n{\n  public void Run(Widget w) => w.Render();\n}\n",
3942            ),
3943        ]);
3944        let g = resolve_graph(&no_git_root(), &files);
3945        assert!(
3946            member_edges_from(&g, "Consumers/WrongReceiver.cs").is_empty(),
3947            "the method name matches but the this-type does not -- no edge"
3948        );
3949    }
3950
3951    #[test]
3952    fn stage3_tier_f_an_instance_member_shadows_a_visible_extension_of_the_same_name() {
3953        // MUTATION-CRITICAL: this is what tier (e)'s `emitted = true` buys.
3954        // Drop that assignment and BOTH tiers claim the ref, producing two
3955        // edges -- the count assertion below is the one that catches it.
3956        let files = fragments_for(&[
3957            ("Other/Widget.cs", "namespace App.Other { public class Widget { public void Render() { } } }"),
3958            WIDGET_EXTENSIONS_SRC,
3959            (
3960                "Consumers/Shadowed.cs",
3961                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class Shadowed\n{\n  public void Run(Widget w) => w.Render();\n}\n",
3962            ),
3963        ]);
3964        let g = resolve_graph(&no_git_root(), &files);
3965        assert_eq!(
3966            member_edges_from(&g, "Consumers/Shadowed.cs"),
3967            vec![("App.Other.Widget", 9)],
3968            "exactly one edge, and C#'s shadowing rule falls out of tier order: the instance member wins"
3969        );
3970        // Tier (e) is precise -- only tier (f) emits heuristic edges. And the
3971        // ref is claimed, so the scored tier never runs on it either: one ref,
3972        // one answer.
3973        assert!(heuristic_member_edges_from(&g, "Consumers/Shadowed.cs").is_empty());
3974        assert_eq!(
3975            g.stats.edges_by_kind.uses_member, 1,
3976            "a precise edge still counts in edges_by_kind"
3977        );
3978        assert_eq!(g.stats.heuristic_edge_count, 0);
3979    }
3980
3981    #[test]
3982    fn stage3_tier_f_a_ref_with_no_receiver_type_never_enters_the_extension_tier() {
3983        let files = fragments_for(&[
3984            WIDGET_SRC,
3985            WIDGET_EXTENSIONS_SRC,
3986            (
3987                "Consumers/NoReceiverFact.cs",
3988                // A TYPE-name qualifier resolves to App.Other.Widget through
3989                // the ladder, but extension methods are instance-call syntax
3990                // only, so "Widget.Render()" must never be claimed here. And a
3991                // receiver the extractor refused to vouch for (var + a call)
3992                // carries no receiverType, so no lookup key exists at all.
3993                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class NoReceiverFact\n{\n  public void Static() => Widget.Render();\n\n  public void Unknown()\n  {\n    var w = Compute();\n    w.Render();\n  }\n}\n",
3994            ),
3995        ]);
3996        let g = resolve_graph(&no_git_root(), &files);
3997        assert!(
3998            member_edges_from(&g, "Consumers/NoReceiverFact.cs").is_empty(),
3999            "neither shape carries a receiver fact, so neither can reach the extension tier"
4000        );
4001        // The scored tier draws the line between the two shapes tier (f)
4002        // treated alike. `Widget.Render()` RESOLVED -- a resolved qualifier is a fact
4003        // the precise tiers already judged, so the scored tier refuses to
4004        // second-guess it and emits nothing. `w.Render()` resolved to nothing
4005        // at all, which is the only door into the uniqueness fallback, and the
4006        // extension class is a candidate there because the fallback counts
4007        // extension-method names too (`member_vouched`, not `declares_member`).
4008        assert_eq!(
4009            heuristic_member_edges_from(&g, "Consumers/NoReceiverFact.cs"),
4010            vec![("App.Ext.WidgetExtensions", 14)]
4011        );
4012    }
4013
4014    #[test]
4015    fn stage3_tier_f_bound_this_type_matching_is_exact_so_a_base_class_param_never_claims_a_derived_receiver(
4016    ) {
4017        let files = fragments_for(&[
4018            ("Other/BaseWidget.cs", "namespace App.Other { public class BaseWidget { } }"),
4019            ("Other/Widget.cs", "namespace App.Other { public class Widget : BaseWidget { } }"),
4020            (
4021                "Ext/BaseExtensions.cs",
4022                "namespace App.Ext { public static class BaseExtensions { public static void Render(this BaseWidget b) { } } }",
4023            ),
4024            (
4025                "Consumers/Derived.cs",
4026                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class Derived\n{\n  public void Run(Widget w) => w.Render();\n}\n",
4027            ),
4028        ]);
4029        let g = resolve_graph(&no_git_root(), &files);
4030        assert!(
4031            member_edges_from(&g, "Consumers/Derived.cs").is_empty(),
4032            "documented limitation: no inheritance walking and no interface widening -- real C# WOULD bind this, the resolver stays narrower rather than guessing"
4033        );
4034    }
4035
4036    // --- tighten amendment: arity is part of the match ---
4037    //
4038    // Real fixtures run through this crate's own extractor, so an arity or
4039    // arg_count that never gets recorded fails here rather than passing
4040    // vacuously.
4041
4042    #[test]
4043    fn stage3_tighten_regression_a_three_argument_call_never_binds_to_a_one_parameter_extension() {
4044        let files = fragments_for(&[
4045            WIDGET_SRC,
4046            (
4047                "Ext/WidgetExtensions.cs",
4048                "namespace App.Ext { public static class WidgetExtensions { public static void Render(this Widget w, int depth) { } } }",
4049            ),
4050            (
4051                "Consumers/ArityMismatch.cs",
4052                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class ArityMismatch\n{\n  public void Wrong(Widget w) => w.Render(1, 2, 3);\n  public void Right(Widget w) => w.Render(1);\n}\n",
4053            ),
4054        ]);
4055        let g = resolve_graph(&no_git_root(), &files);
4056        assert_eq!(
4057            heuristic_member_edges_from(&g, "Consumers/ArityMismatch.cs"),
4058            vec![("App.Ext.WidgetExtensions", 10)],
4059            "corpus audit round 1 found 3/20 wrong edges of exactly this shape: an arity-blind index let a 3-argument call (line 9) claim a 1-parameter extension, stealing the edge from the real instance method. Only the arity-MATCHED call on line 10 survives"
4060        );
4061        assert_eq!(
4062            g.stats.ambiguous_count, 0,
4063            "the refused arity mismatch is silent, like every other uses-member miss"
4064        );
4065    }
4066
4067    #[test]
4068    fn stage3_tighten_a_property_read_never_enters_the_extension_tier() {
4069        // A 0-arity extension is exactly what an argCount-blind tier would have
4070        // matched a property read against, since a property read has no
4071        // arguments to disagree about. It carries no argCount AT ALL, which is
4072        // the actual gate: an extension method is reachable through call syntax
4073        // only.
4074        let files = fragments_for(&[
4075            WIDGET_SRC,
4076            (
4077                "Ext/SlugExtensions.cs",
4078                "namespace App.Ext { public static class SlugExtensions { public static string Slug(this Widget w) => \"s\"; } }",
4079            ),
4080            (
4081                "Consumers/PropertyRead.cs",
4082                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class PropertyRead\n{\n  public string Read(Widget w) => w.Slug;\n}\n",
4083            ),
4084        ]);
4085        let consumer = &files
4086            .iter()
4087            .find(|(rel, _)| rel == "Consumers/PropertyRead.cs")
4088            .expect("consumer fragment")
4089            .1;
4090        let r = consumer
4091            .refs
4092            .iter()
4093            .find(|r| r.member.as_deref() == Some("Slug"))
4094            .expect("Slug ref present");
4095        assert_eq!(
4096            r.receiver_type.as_deref(),
4097            Some("Widget"),
4098            "the receiver fact still fires -- it is the argCount that is absent"
4099        );
4100        assert_eq!(
4101            r.arg_count, None,
4102            "a property read is not an invocation, so it records no argCount"
4103        );
4104
4105        let g = resolve_graph(&no_git_root(), &files);
4106        assert!(
4107            member_edges_from(&g, "Consumers/PropertyRead.cs").is_empty(),
4108            "no argCount, no key, no candidate lookup at all"
4109        );
4110    }
4111
4112    #[test]
4113    fn stage3_range_an_optional_parameter_makes_the_entry_a_range_and_every_count_inside_it_binds()
4114    {
4115        let files = fragments_for(&[
4116            WIDGET_SRC,
4117            (
4118                "Ext/OptExtensions.cs",
4119                "namespace App.Ext { public static class OptExtensions { public static void Render(this Widget w, int depth, string label = null) { } } }",
4120            ),
4121            (
4122                "Consumers/OptionalRange.cs",
4123                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class OptionalRange\n{\n  public void One(Widget w) => w.Render(1);\n  public void Two(Widget w) => w.Render(1, \"a\");\n  public void Three(Widget w) => w.Render(1, \"a\", 3);\n}\n",
4124            ),
4125        ]);
4126        let ext = &files
4127            .iter()
4128            .find(|(rel, _)| rel == "Ext/OptExtensions.cs")
4129            .expect("ext fragment")
4130            .1;
4131        let d = ext
4132            .defs
4133            .iter()
4134            .find(|d| d.id == "App.Ext.OptExtensions")
4135            .expect("OptExtensions def present");
4136        assert_eq!(
4137            d.extension_methods
4138                .iter()
4139                .map(|e| (
4140                    e.name.as_str(),
4141                    e.this_type.as_str(),
4142                    e.arity_min,
4143                    e.arity_max
4144                ))
4145                .collect::<Vec<_>>(),
4146            vec![("Render", "Widget", 1, 2)],
4147            "arityMin skips the defaulted parameter; arityMax still counts every DECLARED one"
4148        );
4149
4150        let g = resolve_graph(&no_git_root(), &files);
4151        assert_eq!(
4152            heuristic_member_edges_from(&g, "Consumers/OptionalRange.cs"),
4153            vec![("App.Ext.OptExtensions", 9), ("App.Ext.OptExtensions", 10)],
4154            "one argument and two both fall inside [1, 2]; three falls outside and earns nothing"
4155        );
4156    }
4157
4158    #[test]
4159    fn stage3_tier_f_a_partial_static_class_declaring_the_same_quadruple_twice_stays_one_candidate()
4160    {
4161        // The Rust index stores def INDEXES in each bucket, so the per-def
4162        // dedup has to happen BEFORE the push -- otherwise a partial class
4163        // re-declaring the same (name, thisType, arityMin, arityMax) in a second
4164        // file would fill its own bucket twice and the one-candidate gate would
4165        // refuse a call that has exactly one real candidate.
4166        let files = vec![
4167            (
4168                "Ext/Widget.cs".to_string(),
4169                frag(
4170                    vec![def("App.Other.Widget", "Widget", "App.Other", "class")],
4171                    vec![],
4172                    vec![],
4173                ),
4174            ),
4175            (
4176                "Ext/Part1.cs".to_string(),
4177                frag(
4178                    vec![ext_def(
4179                        "App.Ext.Helpers",
4180                        "Helpers",
4181                        "App.Ext",
4182                        &[("Render", "Widget", 0, 0)],
4183                    )],
4184                    vec![],
4185                    vec![],
4186                ),
4187            ),
4188            (
4189                "Ext/Part2.cs".to_string(),
4190                frag(
4191                    vec![ext_def(
4192                        "App.Ext.Helpers",
4193                        "Helpers",
4194                        "App.Ext",
4195                        &[("Render", "Widget", 0, 0), ("Poke", "Widget", 0, 0)],
4196                    )],
4197                    vec![],
4198                    vec![],
4199                ),
4200            ),
4201            (
4202                "Consumers/PartialExt.cs".to_string(),
4203                frag(
4204                    vec![def(
4205                        "App.Consumers.PartialExt",
4206                        "PartialExt",
4207                        "App.Consumers",
4208                        "class",
4209                    )],
4210                    vec![FragUsing::Plain {
4211                        text: "App.Ext".into(),
4212                        global: false,
4213                    }],
4214                    // Distinct LINES: the two calls guess the same static class,
4215                    // and the heuristic-side dedup collapses byte-identical
4216                    // guesses -- which a shared synthetic line would make these,
4217                    // hiding the second candidate this test exists to see.
4218                    vec![
4219                        receiver_ref("w", "Render", "App.Consumers", "Widget", Some(0)),
4220                        FragRef {
4221                            line: 2,
4222                            ..receiver_ref("w", "Poke", "App.Consumers", "Widget", Some(0))
4223                        },
4224                    ],
4225                ),
4226            ),
4227        ];
4228        let g = resolve_graph(&no_git_root(), &files);
4229        assert_eq!(
4230            heuristic_member_edge_targets(&g),
4231            vec!["App.Ext.Helpers", "App.Ext.Helpers"],
4232            "the duplicate pair is deduped, and the second file's NEW pair still registers"
4233        );
4234    }
4235
4236    // --- second tighten: instance-member veto, arity range,
4237    // --- generic argument unification ---
4238    //
4239    // Real fixtures run through this crate's own extractor, so a range, a base
4240    // name, or a type-argument descriptor that never gets recorded fails here
4241    // rather than passing vacuously.
4242
4243    #[test]
4244    fn stage3_range_regression_an_exact_arity_class_no_longer_looks_unique_next_to_a_range_class() {
4245        let files = fragments_for(&[
4246            ("Other/Bus.cs", "namespace App.Other { public class Bus { } }"),
4247            // Exactly two parameters -- the shape the arity-keyed index used to
4248            // hand the edge to, because the range class below was keyed under
4249            // arity 3 and could not be found at argCount 2 at all.
4250            (
4251                "ExtA/WrongSend.cs",
4252                "namespace App.ExtA { public static class WrongSend { public static void Send(this Bus b, object m, int retries) { } } }",
4253            ),
4254            (
4255                "ExtB/RightSend.cs",
4256                "namespace App.ExtB { public static class RightSend { public static void Send(this Bus b, object m, string topic, int retries = 0) { } } }",
4257            ),
4258            (
4259                "Consumers/TwoSends.cs",
4260                "\nusing App.Other;\nusing App.ExtA;\nusing App.ExtB;\n\nnamespace App.Consumers;\n\npublic class TwoSends\n{\n  public void Run(Bus b) => b.Send(1, 2);\n}\n",
4261            ),
4262        ]);
4263        let g = resolve_graph(&no_git_root(), &files);
4264        assert!(
4265            member_edges_from(&g, "Consumers/TwoSends.cs").is_empty(),
4266            "both classes accept two arguments once the range is honoured, so the tier sees TWO candidates and refuses -- the arity-keyed index saw one and picked the wrong class"
4267        );
4268        assert_eq!(
4269            g.stats.ambiguous_count, 0,
4270            "honest ambiguity here is still silence, not an ambiguous edge"
4271        );
4272    }
4273
4274    #[test]
4275    fn stage3_range_a_params_array_records_arity_max_minus_one_and_accepts_any_count() {
4276        let files = fragments_for(&[
4277            WIDGET_SRC,
4278            (
4279                "Ext/ParamsExtensions.cs",
4280                "namespace App.Ext { public static class ParamsExtensions { public static void All(this Widget w, params int[] xs) { } } }",
4281            ),
4282            (
4283                "Consumers/Spread.cs",
4284                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class Spread\n{\n  public void None(Widget w) => w.All();\n  public void Five(Widget w) => w.All(1, 2, 3, 4, 5);\n}\n",
4285            ),
4286        ]);
4287        let ext = &files
4288            .iter()
4289            .find(|(rel, _)| rel == "Ext/ParamsExtensions.cs")
4290            .expect("ext fragment")
4291            .1;
4292        let d = ext
4293            .defs
4294            .iter()
4295            .find(|d| d.id == "App.Ext.ParamsExtensions")
4296            .expect("ParamsExtensions def present");
4297        assert_eq!(
4298            d.extension_methods
4299                .iter()
4300                .map(|e| (e.arity_min, e.arity_max))
4301                .collect::<Vec<_>>(),
4302            vec![(0, -1)],
4303            "a params array is optional AND unbounded: nothing forces it, nothing caps it"
4304        );
4305
4306        let g = resolve_graph(&no_git_root(), &files);
4307        assert_eq!(
4308            heuristic_member_edges_from(&g, "Consumers/Spread.cs"),
4309            vec![
4310                ("App.Ext.ParamsExtensions", 9),
4311                ("App.Ext.ParamsExtensions", 10)
4312            ],
4313            "zero arguments and five both bind to the same unbounded entry"
4314        );
4315    }
4316
4317    #[test]
4318    fn stage3_range_an_unbounded_params_entry_alongside_a_second_visible_class_still_drops() {
4319        let files = fragments_for(&[
4320            WIDGET_SRC,
4321            (
4322                "ExtA/ParamsExtensions.cs",
4323                "namespace App.ExtA { public static class ParamsExtensions { public static void All(this Widget w, params int[] xs) { } } }",
4324            ),
4325            (
4326                "ExtB/ExactExtensions.cs",
4327                "namespace App.ExtB { public static class ExactExtensions { public static void All(this Widget w, int a, int b) { } } }",
4328            ),
4329            (
4330                "Consumers/SpreadTwo.cs",
4331                "\nusing App.Other;\nusing App.ExtA;\nusing App.ExtB;\n\nnamespace App.Consumers;\n\npublic class SpreadTwo\n{\n  public void Two(Widget w) => w.All(1, 2);\n}\n",
4332            ),
4333        ]);
4334        let g = resolve_graph(&no_git_root(), &files);
4335        assert!(
4336            member_edges_from(&g, "Consumers/SpreadTwo.cs").is_empty(),
4337            "an unbounded range never wins a tie -- two candidates is two candidates"
4338        );
4339    }
4340
4341    #[test]
4342    fn stage3_veto_a_member_declared_by_the_receivers_interface_beats_a_matching_visible_extension()
4343    {
4344        let files = fragments_for(&[
4345            ("Other/IWidget.cs", "namespace App.Other { public interface IWidget { void Render(int depth); } }"),
4346            ("Other/Widget.cs", "namespace App.Other { public class Widget : IWidget { } }"),
4347            (
4348                "Ext/WidgetExtensions.cs",
4349                "namespace App.Ext { public static class WidgetExtensions { public static void Render(this Widget w, int depth) { } } }",
4350            ),
4351            (
4352                "Consumers/Vetoed.cs",
4353                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class Vetoed\n{\n  public void Run(Widget w) => w.Render(1);\n}\n",
4354            ),
4355        ]);
4356        let g = resolve_graph(&no_git_root(), &files);
4357        assert!(
4358            member_edges_from(&g, "Consumers/Vetoed.cs").is_empty(),
4359            "C# binds the instance member the interface declares; the extension is unreachable, so the tier must not claim the ref"
4360        );
4361        // Tier (e) must NOT have widened either: it emits only on the exact
4362        // receiver def, and Widget itself declares nothing.
4363        assert_eq!(g.stats.ambiguous_count, 0);
4364    }
4365
4366    #[test]
4367    fn stage3_veto_control_the_same_shape_with_the_member_absent_still_earns_its_extension_edge() {
4368        let files = fragments_for(&[
4369            ("Other/IWidget.cs", "namespace App.Other { public interface IWidget { void Measure(int depth); } }"),
4370            ("Other/Widget.cs", "namespace App.Other { public class Widget : IWidget { } }"),
4371            (
4372                "Ext/WidgetExtensions.cs",
4373                "namespace App.Ext { public static class WidgetExtensions { public static void Render(this Widget w, int depth) { } } }",
4374            ),
4375            (
4376                "Consumers/NotVetoed.cs",
4377                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class NotVetoed\n{\n  public void Run(Widget w) => w.Render(1);\n}\n",
4378            ),
4379        ]);
4380        let g = resolve_graph(&no_git_root(), &files);
4381        assert_eq!(
4382            heuristic_member_edges_from(&g, "Consumers/NotVetoed.cs"),
4383            vec![("App.Ext.WidgetExtensions", 9)],
4384            "the closure declares Measure, not Render -- nothing vetoes"
4385        );
4386    }
4387
4388    #[test]
4389    fn stage3_veto_the_closure_is_transitive_so_a_member_on_the_base_of_the_base_still_vetoes() {
4390        let files = fragments_for(&[
4391            ("Other/Root.cs", "namespace App.Other { public class Root { public void Render(int depth) { } } }"),
4392            ("Other/Middle.cs", "namespace App.Other { public class Middle : Root { } }"),
4393            ("Other/Leaf.cs", "namespace App.Other { public class Leaf : Middle { } }"),
4394            (
4395                "Ext/LeafExtensions.cs",
4396                "namespace App.Ext { public static class LeafExtensions { public static void Render(this Leaf l, int depth) { } } }",
4397            ),
4398            (
4399                "Consumers/DeepVeto.cs",
4400                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class DeepVeto\n{\n  public void Run(Leaf l) => l.Render(1);\n}\n",
4401            ),
4402        ]);
4403        let g = resolve_graph(&no_git_root(), &files);
4404        assert!(
4405            member_edges_from(&g, "Consumers/DeepVeto.cs").is_empty(),
4406            "two hops up the chain is still an instance member"
4407        );
4408    }
4409
4410    #[test]
4411    fn stage3_veto_a_cycle_in_the_base_closure_terminates_instead_of_hanging() {
4412        // Not legal C#, but a fragments cache assembled from mid-edit sources
4413        // can present exactly this, and an unbounded walk is not an acceptable
4414        // failure mode.
4415        let files = fragments_for(&[
4416            ("Other/A.cs", "namespace App.Other { public class A : B { } }"),
4417            ("Other/B.cs", "namespace App.Other { public class B : A { } }"),
4418            (
4419                "Ext/AExtensions.cs",
4420                "namespace App.Ext { public static class AExtensions { public static void Render(this A a, int depth) { } } }",
4421            ),
4422            (
4423                "Consumers/Cyclic.cs",
4424                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class Cyclic\n{\n  public void Run(A a) => a.Render(1);\n}\n",
4425            ),
4426        ]);
4427        let g = resolve_graph(&no_git_root(), &files);
4428        assert_eq!(
4429            heuristic_member_edges_from(&g, "Consumers/Cyclic.cs"),
4430            vec![("App.Ext.AExtensions", 9)],
4431            "the walk terminates and, finding no Render in the cycle, lets the extension edge stand"
4432        );
4433    }
4434
4435    #[test]
4436    fn stage3_veto_bound_an_external_receiver_type_can_never_be_vetoed() {
4437        // No definition of `HttpClient` anywhere in the graph -- the receiver
4438        // resolves to nothing, so no closure exists to inspect.
4439        let files = fragments_for(&[
4440            (
4441                "Ext/HttpExtensions.cs",
4442                "namespace App.Ext { public static class HttpExtensions { public static void Ping(this HttpClient c, int n) { } } }",
4443            ),
4444            (
4445                "Consumers/External.cs",
4446                "\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class External\n{\n  public void Run(HttpClient c) => c.Ping(1);\n}\n",
4447            ),
4448        ]);
4449        let g = resolve_graph(&no_git_root(), &files);
4450        assert_eq!(
4451            heuristic_member_edges_from(&g, "Consumers/External.cs"),
4452            vec![("App.Ext.HttpExtensions", 8)],
4453            "documented bound: an out-of-graph receiver hides whatever members it declares"
4454        );
4455    }
4456
4457    #[test]
4458    fn stage3_generic_concrete_this_args_must_match_the_receivers_concrete_args() {
4459        let files = fragments_for(&[
4460            (
4461                "Other/Types.cs",
4462                "namespace App.Other\n{\n  public class IDictionary<TKey, TValue> { }\n  public class IMessageDeserializer { }\n}\n",
4463            ),
4464            (
4465                "Ext/DictExtensions.cs",
4466                "namespace App.Ext { public static class DictExtensions { public static void TryGetValue(this IDictionary<string, object> d, int k) { } } }",
4467            ),
4468            (
4469                "Consumers/WrongArgs.cs",
4470                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class WrongArgs\n{\n  public void Run(IDictionary<string, IMessageDeserializer> d) => d.TryGetValue(1);\n}\n",
4471            ),
4472        ]);
4473        let g = resolve_graph(&no_git_root(), &files);
4474        assert!(
4475            member_edges_from(&g, "Consumers/WrongArgs.cs").is_empty(),
4476            "the base name and arity both match -- only the type ARGUMENTS disagree, which is exactly the wrong edge the corpus audit found"
4477        );
4478    }
4479
4480    #[test]
4481    fn stage3_generic_exactly_matching_concrete_this_args_earn_the_edge() {
4482        let files = fragments_for(&[
4483            ("Other/Types.cs", "namespace App.Other { public class IDictionary<TKey, TValue> { } }"),
4484            (
4485                "Ext/DictExtensions.cs",
4486                "namespace App.Ext { public static class DictExtensions { public static void TryGetValue(this IDictionary<string, object> d, int k) { } } }",
4487            ),
4488            (
4489                "Consumers/RightArgs.cs",
4490                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class RightArgs\n{\n  public void Run(IDictionary<string, object> d) => d.TryGetValue(1);\n}\n",
4491            ),
4492        ]);
4493        let g = resolve_graph(&no_git_root(), &files);
4494        assert_eq!(
4495            heuristic_member_edges_from(&g, "Consumers/RightArgs.cs"),
4496            vec![("App.Ext.DictExtensions", 9)]
4497        );
4498    }
4499
4500    #[test]
4501    fn stage3_generic_a_wildcard_this_arg_unifies_with_an_unbound_method_type_parameter() {
4502        let files = fragments_for(&[
4503            (
4504                "Other/Types.cs",
4505                "namespace App.Other\n{\n  public class EventPipelineBinder<TSaga, TData> { }\n  public class FutureState { }\n}\n",
4506            ),
4507            (
4508                "Ext/BinderExtensions.cs",
4509                "namespace App.Ext\n{\n  public static class BinderExtensions\n  {\n    public static void Then<TSaga, TData>(this EventPipelineBinder<TSaga, TData> b, int a) { }\n  }\n}\n",
4510            ),
4511            (
4512                "Consumers/Wildcards.cs",
4513                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class Wildcards\n{\n  public void Run<T>(EventPipelineBinder<FutureState, T> b) => b.Then(1);\n}\n",
4514            ),
4515        ]);
4516        let ext = &files
4517            .iter()
4518            .find(|(rel, _)| rel == "Ext/BinderExtensions.cs")
4519            .expect("ext fragment")
4520            .1;
4521        let d = ext
4522            .defs
4523            .iter()
4524            .find(|d| d.id == "App.Ext.BinderExtensions")
4525            .expect("BinderExtensions def present");
4526        assert_eq!(
4527            d.extension_methods[0].this_args,
4528            Some(vec!["*".to_string(), "*".to_string()]),
4529            "the extension's own type parameters are wildcards"
4530        );
4531        let consumer = &files
4532            .iter()
4533            .find(|(rel, _)| rel == "Consumers/Wildcards.cs")
4534            .expect("consumer fragment")
4535            .1;
4536        let r = consumer
4537            .refs
4538            .iter()
4539            .find(|r| r.member.as_deref() == Some("Then"))
4540            .expect("Then ref present");
4541        assert_eq!(
4542            r.receiver_args,
4543            Some(vec!["FutureState".to_string(), "*".to_string()]),
4544            "the enclosing method's own type parameter is a wildcard on the receiver side too"
4545        );
4546
4547        let g = resolve_graph(&no_git_root(), &files);
4548        assert_eq!(
4549            heuristic_member_edges_from(&g, "Consumers/Wildcards.cs"),
4550            vec![("App.Ext.BinderExtensions", 9)]
4551        );
4552    }
4553
4554    #[test]
4555    fn stage3_generic_a_non_generic_receiver_never_binds_a_generic_this_parameter() {
4556        let files = fragments_for(&[
4557            ("Other/Types.cs", "namespace App.Other\n{\n  public class Box<T> { }\n  public class Widget { }\n}\n"),
4558            (
4559                "Ext/BoxExtensions.cs",
4560                "namespace App.Ext { public static class BoxExtensions { public static void Open(this Box<Widget> b) { } } }",
4561            ),
4562            (
4563                "Consumers/Bare.cs",
4564                "\nusing App.Other;\nusing App.Ext;\n\nnamespace App.Consumers;\n\npublic class Bare\n{\n  public void Run(Box b) => b.Open();\n}\n",
4565            ),
4566        ]);
4567        let consumer = &files
4568            .iter()
4569            .find(|(rel, _)| rel == "Consumers/Bare.cs")
4570            .expect("consumer fragment")
4571            .1;
4572        let r = consumer
4573            .refs
4574            .iter()
4575            .find(|r| r.member.as_deref() == Some("Open"))
4576            .expect("Open ref present");
4577        assert_eq!(r.receiver_type.as_deref(), Some("Box"));
4578        assert_eq!(
4579            r.receiver_args, None,
4580            "a non-generic declared type records no args at all"
4581        );
4582
4583        let g = resolve_graph(&no_git_root(), &files);
4584        assert!(
4585            member_edges_from(&g, "Consumers/Bare.cs").is_empty(),
4586            "generic on one side and not the other is a mismatch, never a wildcard"
4587        );
4588    }
4589
4590    // --- the scored heuristic tier ---
4591    //
4592    // Real fixtures run through this crate's own extractor, so a fact that never
4593    // gets recorded fails here rather than passing vacuously.
4594
4595    #[test]
4596    fn stage4_scored_an_ambiguous_qualifier_names_every_member_declaring_candidate_and_only_those()
4597    {
4598        let files = fragments_for(&[
4599            ("One/Config.cs", "namespace App.One { public class Config { public void Load() { } } }"),
4600            ("Two/Config.cs", "namespace App.Two { public class Config { public void Load() { } } }"),
4601            // Same simple name, so it IS one of the ambiguous candidates the
4602            // ladder hands over -- but it declares nothing called Load, so the
4603            // member filter drops it. The pool is never "everything the ladder
4604            // was confused by".
4605            ("Three/Config.cs", "namespace App.Three { public class Config { public void Save() { } } }"),
4606            (
4607                "Consumers/AmbiguousQualifier.cs",
4608                "\nnamespace App.Consumers;\n\npublic class AmbiguousQualifier\n{\n  public void Run() => Config.Load();\n}\n",
4609            ),
4610        ]);
4611        let g = resolve_graph(&no_git_root(), &files);
4612        assert!(
4613            member_edges_from(&g, "Consumers/AmbiguousQualifier.cs").is_empty(),
4614            "the precise tiers still refuse to pick"
4615        );
4616        assert_eq!(
4617            heuristic_member_edges_from(&g, "Consumers/AmbiguousQualifier.cs"),
4618            vec![("App.One.Config", 6), ("App.Two.Config", 6)],
4619            "both member-declaring candidates, ordered by def id since nothing separates their scores"
4620        );
4621    }
4622
4623    #[test]
4624    fn stage4_scored_same_namespace_beats_usings_visible_beats_global_and_that_is_the_emitted_order(
4625    ) {
4626        let files = fragments_for(&[
4627            ("Consumers/LocalStore.cs", "namespace App.Consumers { public class LocalStore { public void Persist() { } } }"),
4628            ("Imported/ImportedStore.cs", "namespace App.Imported { public class ImportedStore { public void Persist() { } } }"),
4629            ("Far/FarStore.cs", "namespace App.Far { public class FarStore { public void Persist() { } } }"),
4630            (
4631                "Consumers/Caller.cs",
4632                "\nusing App.Imported;\n\nnamespace App.Consumers;\n\npublic class Caller\n{\n  public void Run()\n  {\n    var s = Build();\n    s.Persist();\n  }\n}\n",
4633            ),
4634        ]);
4635        let g = resolve_graph(&no_git_root(), &files);
4636        assert_eq!(
4637            heuristic_member_edge_targets(&g),
4638            vec!["App.Consumers.LocalStore", "App.Imported.ImportedStore", "App.Far.FarStore"],
4639            "score 3 then 2 then 1 -- NOT def-id order, which would have put App.Consumers, App.Far, App.Imported"
4640        );
4641    }
4642
4643    #[test]
4644    fn stage4_scored_the_uniqueness_fallback_emits_at_two_member_declaring_defs() {
4645        let files = fragments_for(&[
4646            ("A/Counter.cs", "namespace App.A { public class Counter { public void Tally() { } } }"),
4647            ("B/Ledger.cs", "namespace App.B { public class Ledger { public void Tally() { } } }"),
4648            (
4649                "Consumers/Unknown.cs",
4650                "\nnamespace App.Consumers;\n\npublic class Unknown\n{\n  public void Run()\n  {\n    var x = Build();\n    x.Tally();\n  }\n}\n",
4651            ),
4652        ]);
4653        let g = resolve_graph(&no_git_root(), &files);
4654        assert_eq!(
4655            heuristic_member_edge_targets(&g),
4656            vec!["App.A.Counter", "App.B.Ledger"]
4657        );
4658    }
4659
4660    #[test]
4661    fn stage4_scored_a_member_name_carried_by_four_defs_is_too_common_to_guess_from() {
4662        let files = fragments_for(&[
4663            ("A/Counter.cs", "namespace App.A { public class Counter { public void Tally() { } } }"),
4664            ("B/Ledger.cs", "namespace App.B { public class Ledger { public void Tally() { } } }"),
4665            ("C/Register.cs", "namespace App.C { public class Register { public void Tally() { } } }"),
4666            ("D/Book.cs", "namespace App.D { public class Book { public void Tally() { } } }"),
4667            (
4668                "Consumers/TooCommon.cs",
4669                "\nnamespace App.Consumers;\n\npublic class TooCommon\n{\n  public void Run()\n  {\n    var x = Build();\n    x.Tally();\n  }\n}\n",
4670            ),
4671        ]);
4672        let g = resolve_graph(&no_git_root(), &files);
4673        assert!(
4674            heuristic_member_edges_from(&g, "Consumers/TooCommon.cs").is_empty(),
4675            "the refusal is total, not a top-three slice: past the threshold the name carries no information at all"
4676        );
4677        assert_eq!(g.stats.heuristic_edge_count, 0);
4678    }
4679
4680    #[test]
4681    fn stage4_scored_an_ambiguous_pool_larger_than_the_emit_cap_yields_exactly_three() {
4682        let files = fragments_for(&[
4683            ("A/Repo.cs", "namespace App.A { public class Repo { public void Save() { } } }"),
4684            ("B/Repo.cs", "namespace App.B { public class Repo { public void Save() { } } }"),
4685            ("C/Repo.cs", "namespace App.C { public class Repo { public void Save() { } } }"),
4686            ("D/Repo.cs", "namespace App.D { public class Repo { public void Save() { } } }"),
4687            ("E/Repo.cs", "namespace App.E { public class Repo { public void Save() { } } }"),
4688            ("Consumers/Many.cs", "\nnamespace App.Consumers;\n\npublic class Many\n{\n  public void Run() => Repo.Save();\n}\n"),
4689        ]);
4690        let g = resolve_graph(&no_git_root(), &files);
4691        let guesses = heuristic_member_edges_from(&g, "Consumers/Many.cs");
4692        assert_eq!(
4693            guesses.len(),
4694            3,
4695            "the emit cap binds on the AMBIGUOUS pool, which has no size threshold of its own"
4696        );
4697        assert_eq!(
4698            guesses.iter().map(|(to, _)| *to).collect::<Vec<_>>(),
4699            vec!["App.A.Repo", "App.B.Repo", "App.C.Repo"],
4700            "all five score 1 at the global ladder step, so the def-id tiebreak alone decides which three survive"
4701        );
4702    }
4703
4704    #[test]
4705    fn stage4_scored_a_ref_a_precise_tier_already_answered_never_gets_a_heuristic_duplicate() {
4706        let files = fragments_for(&[
4707            // Two more defs declaring Render, so the uniqueness fallback WOULD
4708            // have a pool to draw from if it were ever reached for this ref.
4709            ("Other/Widget.cs", "namespace App.Other { public class Widget { public void Render() { } } }"),
4710            ("Other/Gadget.cs", "namespace App.Other { public class Gadget { public void Render() { } } }"),
4711            (
4712                "Consumers/Precise.cs",
4713                "\nusing App.Other;\n\nnamespace App.Consumers;\n\npublic class Precise\n{\n  private Widget _widget;\n\n  public void Run() => _widget.Render();\n}\n",
4714            ),
4715        ]);
4716        let g = resolve_graph(&no_git_root(), &files);
4717        assert_eq!(
4718            member_edges_from(&g, "Consumers/Precise.cs"),
4719            vec![("App.Other.Widget", 10)],
4720            "one ref, one answer -- a fact is never restated as a guess"
4721        );
4722        assert!(heuristic_member_edges_from(&g, "Consumers/Precise.cs").is_empty());
4723        assert_eq!(g.stats.heuristic_edge_count, 0);
4724    }
4725
4726    #[test]
4727    fn stage4_scored_a_qualifier_that_resolved_but_vouched_for_nothing_is_left_alone() {
4728        let files = fragments_for(&[
4729            // Widget resolves uniquely and simply does not declare Render. That
4730            // is a KNOWN answer ("not here"), not an unknown one, so the scored
4731            // tier -- which only ever reads AMBIGUOUS or nothing-at-all
4732            // outcomes -- must not fire, even though Gadget would be a tidy
4733            // single-candidate guess.
4734            ("Other/Widget.cs", "namespace App.Other { public class Widget { } }"),
4735            ("Other/Gadget.cs", "namespace App.Other { public class Gadget { public void Render() { } } }"),
4736            (
4737                "Consumers/ResolvedMiss.cs",
4738                "\nusing App.Other;\n\nnamespace App.Consumers;\n\npublic class ResolvedMiss\n{\n  public void Run() => Widget.Render();\n}\n",
4739            ),
4740        ]);
4741        let g = resolve_graph(&no_git_root(), &files);
4742        assert!(member_edges_from(&g, "Consumers/ResolvedMiss.cs").is_empty());
4743        assert!(heuristic_member_edges_from(&g, "Consumers/ResolvedMiss.cs").is_empty());
4744    }
4745
4746    // The byte-identity fixture: a fixed set of sources whose resolved edge and
4747    // stats bytes are pinned exactly.
4748    const BYTE_IDENTITY_FIXTURE: &[(&str, &str)] = &[
4749        ("Core/Status.cs", "\nnamespace App.Core;\n\npublic enum Status\n{\n  Active,\n  Idle\n}\n"),
4750        ("Core/IWidget.cs", "namespace App.Core { public interface IWidget { void Render(); } }"),
4751        (
4752            "Core/Widget.cs",
4753            "\nusing App.Core;\n\nnamespace App.Core;\n\npublic class Widget : IWidget\n{\n  public string Name { get; set; }\n\n  public void Render() { }\n}\n",
4754        ),
4755        ("Alpha/Config.cs", "namespace App.Alpha { public class Config { public void Load() { } } }"),
4756        ("Beta/Config.cs", "namespace App.Beta { public class Config { public void Load() { } } }"),
4757        (
4758            "Consumers/Consumer.cs",
4759            "\nusing App.Core;\nusing App.Alpha;\nusing App.Beta;\n\nnamespace App.Consumers;\n\npublic class Consumer\n{\n  private Widget _widget;\n\n  public void Run()\n  {\n    _widget.Render();\n    var s = Status.Active;\n    Config.Load();\n    var c = Compute();\n    c.Tally();\n  }\n}\n",
4760        ),
4761        ("Solo/Counter.cs", "namespace App.Solo { public class Counter { public void Tally() { } } }"),
4762    ];
4763
4764    // The precise-only bytes of that fixture's edge array (the heuristic edges
4765    // dropped). A literal on purpose: a golden recomputed by the code under test
4766    // proves nothing.
4767    const PRE_STAGE4_EDGE_ROWS: &[&str] = &[
4768        r#"{"kind":"imports","from_file":"Core/Widget.cs","from_line":2,"target":"App.Core"}"#,
4769        r#"{"kind":"inherits","from_file":"Core/Widget.cs","from_line":6,"to":"App.Core.IWidget","to_file":"Core/IWidget.cs"}"#,
4770        r#"{"kind":"imports","from_file":"Consumers/Consumer.cs","from_line":2,"target":"App.Core"}"#,
4771        r#"{"kind":"imports","from_file":"Consumers/Consumer.cs","from_line":3,"target":"App.Alpha"}"#,
4772        r#"{"kind":"imports","from_file":"Consumers/Consumer.cs","from_line":4,"target":"App.Beta"}"#,
4773        r#"{"kind":"uses-type","from_file":"Consumers/Consumer.cs","from_line":10,"to":"App.Core.Widget","to_file":"Core/Widget.cs"}"#,
4774        r#"{"kind":"uses-member","from_file":"Consumers/Consumer.cs","from_line":14,"to":"App.Core.Widget","to_file":"Core/Widget.cs"}"#,
4775        r#"{"kind":"uses-member","from_file":"Consumers/Consumer.cs","from_line":15,"to":"App.Core.Status.Active","to_file":"Core/Status.cs"}"#,
4776    ];
4777
4778    #[test]
4779    fn stage4_byte_identity_dropping_the_heuristic_edges_reproduces_the_pre_stage4_edge_array() {
4780        let files = fragments_for(BYTE_IDENTITY_FIXTURE);
4781        let g = resolve_graph(&no_git_root(), &files);
4782
4783        let precise: Vec<&Edge> = g
4784            .edges
4785            .iter()
4786            .filter(|e| {
4787                !matches!(
4788                    e,
4789                    Edge::Inherits {
4790                        heuristic: true,
4791                        ..
4792                    } | Edge::UsesType {
4793                        heuristic: true,
4794                        ..
4795                    } | Edge::UsesMember {
4796                        heuristic: true,
4797                        ..
4798                    }
4799                )
4800            })
4801            .collect();
4802        assert_eq!(
4803            serde_json::to_string(&precise).unwrap(),
4804            format!("[{}]", PRE_STAGE4_EDGE_ROWS.join(",")),
4805            "stage 4 is emission-only: it may ADD tagged edges and may never move, drop or re-key a precise one"
4806        );
4807
4808        // And the addition really happened -- otherwise the assertion above
4809        // would pass just as well on a scored tier that emits nothing at all.
4810        assert_eq!(
4811            heuristic_member_edges_from(&g, "Consumers/Consumer.cs"),
4812            vec![
4813                ("App.Alpha.Config", 16),
4814                ("App.Beta.Config", 16),
4815                ("App.Solo.Counter", 18)
4816            ]
4817        );
4818    }
4819
4820    #[test]
4821    fn stage4_stats_heuristic_edge_count_is_appended_last_and_edges_by_kind_never_counts_a_guess() {
4822        let files = fragments_for(BYTE_IDENTITY_FIXTURE);
4823        let g = resolve_graph(&no_git_root(), &files);
4824
4825        // Whole-object bytes rather than a key list: this pins the ORDER the
4826        // serialized `stats` keys appear in, and the values with them.
4827        assert_eq!(
4828            serde_json::to_string(&g.stats).unwrap(),
4829            r#"{"def_count":9,"file_count":7,"edges_by_kind":{"inherits":1,"uses-type":1,"imports":4,"uses-member":2,"ctor-di":0},"ambiguous_count":0,"ambiguous_pct":0,"unresolved_external_count":0,"heuristic_edge_count":3,"test_def_count":0}"#,
4830            "test_def_count is appended LAST -- the stats key order the Node reference pins"
4831        );
4832        assert_eq!(g.stats.heuristic_edge_count, 3);
4833        assert_eq!(
4834            g.stats.edges_by_kind.uses_member, 2,
4835            "the two precise member edges only -- three heuristic ones landed in the same array and moved this number by zero"
4836        );
4837        assert_eq!(g.stats.edges_by_kind.inherits, 1);
4838        assert_eq!(g.stats.edges_by_kind.uses_type, 1);
4839        assert_eq!(g.stats.edges_by_kind.imports, 4);
4840        assert_eq!(
4841            g.stats.ambiguous_count, 0,
4842            "ambiguous_count semantics are untouched by this stage"
4843        );
4844    }
4845
4846    // --- partial classes: also_in accumulation + method union -------------
4847
4848    #[test]
4849    fn partial_class_across_files_merges_into_also_in_with_method_union() {
4850        let files = vec![
4851            (
4852                "A/Product.cs".to_string(),
4853                frag(
4854                    vec![FragDef {
4855                        line: 3,
4856                        ..def_with(
4857                            "A.Product",
4858                            "Product",
4859                            "A",
4860                            "class",
4861                            &["Describe"],
4862                            &["Name"],
4863                            &["_cache"],
4864                        )
4865                    }],
4866                    vec![],
4867                    vec![],
4868                ),
4869            ),
4870            (
4871                "A/Product.Extra.cs".to_string(),
4872                frag(
4873                    vec![FragDef {
4874                        line: 3,
4875                        ..def_with(
4876                            "A.Product",
4877                            "Product",
4878                            "A",
4879                            "class",
4880                            &["Refresh"],
4881                            &["Sku"],
4882                            &["_extra"],
4883                        )
4884                    }],
4885                    vec![],
4886                    vec![],
4887                ),
4888            ),
4889        ];
4890        let g = resolve_graph(&no_git_root(), &files);
4891        assert_eq!(g.defs.len(), 1, "one def entry, not two");
4892        let d = &g.defs[0];
4893        assert_eq!(
4894            d.file, "A/Product.cs",
4895            "first-insertion file wins as the primary site"
4896        );
4897        assert_eq!(
4898            d.methods,
4899            vec!["Describe".to_string(), "Refresh".to_string()],
4900            "methods union in encounter order"
4901        );
4902        assert_eq!(
4903            d.also_in,
4904            vec![AlsoIn {
4905                file: "A/Product.Extra.cs".into(),
4906                line: 3
4907            }]
4908        );
4909    }
4910
4911    // --- resolution ladder: enclosing-namespace walks at steps 2 and 3 ------
4912
4913    #[test]
4914    fn ladder_step2_a_using_directive_is_itself_read_against_the_enclosing_namespaces() {
4915        let files = fragments_for(&[
4916            ("A/Configuration/Setting.cs", "namespace A.Configuration { public class Setting { } }"),
4917            // The collision partner: without it a bare "Setting" would resolve
4918            // at step 4 (globally unique simple name) and this test would pass
4919            // on a ladder that never walked anything. With it, step 4 can only
4920            // report ambiguous.
4921            ("Other/Setting.cs", "namespace Other { public class Setting { } }"),
4922            ("A/B/C/Holder.cs", "\nusing Configuration;\n\nnamespace A.B.C;\n\npublic class Holder\n{\n  private Setting _setting;\n}\n"),
4923        ]);
4924        let g = resolve_graph(&no_git_root(), &files);
4925        assert_eq!(
4926            type_edge_targets_from(&g, "A/B/C/Holder.cs"),
4927            vec!["A.Configuration.Setting"],
4928            "`using Configuration;` inside A.B.C reaches A.Configuration.Setting"
4929        );
4930        assert!(
4931            !g.edges.iter().any(
4932                |e| matches!(e, Edge::Ambiguous { from_file, .. } if from_file == "A/B/C/Holder.cs")
4933            ),
4934            "step 2 answers, so the ladder never reaches the ambiguous step-4 pool"
4935        );
4936    }
4937
4938    #[test]
4939    fn ladder_step3_the_ancestor_namespace_rule_walks_every_enclosing_namespace() {
4940        let files = fragments_for(&[
4941            ("A/Shared.cs", "namespace A { public class Shared { } }"),
4942            // Same role as above -- makes step 4 ambiguous, so only a step-3
4943            // walk can produce a resolved edge here.
4944            (
4945                "Other/Shared.cs",
4946                "namespace Other { public class Shared { } }",
4947            ),
4948            (
4949                "A/B/C/Deep.cs",
4950                "\nnamespace A.B.C;\n\npublic class Deep\n{\n  private Shared _shared;\n}\n",
4951            ),
4952        ]);
4953        let g = resolve_graph(&no_git_root(), &files);
4954        assert_eq!(
4955            type_edge_targets_from(&g, "A/B/C/Deep.cs"),
4956            vec!["A.Shared"],
4957            "no usings at all -- the ancestor-namespace walk is the only step that can answer"
4958        );
4959        assert!(
4960            !g.edges.iter().any(
4961                |e| matches!(e, Edge::Ambiguous { from_file, .. } if from_file == "A/B/C/Deep.cs")
4962            ),
4963            "a walked step-3 hit resolves and never falls through to the ambiguous step-4 pool"
4964        );
4965    }
4966
4967    #[test]
4968    fn stage4_scored_a_nested_type_candidate_is_refused_from_outside_its_own_file_and_kept_inside_it(
4969    ) {
4970        let files = fragments_for(&[
4971            // Cross-file nested candidate: unreachable from Holder.cs without
4972            // naming Remote first, so a guess landing on it could never be what
4973            // the code said.
4974            (
4975                "Far/Remote.cs",
4976                "\nnamespace App.Far;\n\npublic class Remote\n{\n  public class Inner\n  {\n    public void Tally() { }\n  }\n}\n",
4977            ),
4978            // Same-file nested candidate + the ref itself. `mystery` is a
4979            // var-from-call local, so no receiver fact and no qualifier
4980            // resolution at all -- the only door into the scored tier's
4981            // uniqueness fallback.
4982            (
4983                "Nested/Holder.cs",
4984                "\nnamespace App.Nested;\n\npublic class Outer\n{\n  public class Nested\n  {\n    public void Tally() { }\n  }\n\n  public string Probe()\n  {\n    var mystery = Fetch();\n    mystery.Tally();\n    return \"x\";\n  }\n}\n",
4985            ),
4986        ]);
4987        let g = resolve_graph(&no_git_root(), &files);
4988        assert_eq!(
4989            heuristic_member_edge_targets(&g),
4990            vec!["App.Nested.Outer+Nested"],
4991            "the same-file nested candidate is nameable and stays; the cross-file one is refused"
4992        );
4993    }
4994
4995    #[test]
4996    fn heuristic_side_dedup_collapses_byte_identical_guesses_and_keeps_an_identical_precise_pair() {
4997        let files = fragments_for(&[
4998            ("Widgets/Widget.cs", "namespace App.Widgets { public class Widget { } }"),
4999            (
5000                "Ext/Helpers.cs",
5001                "\nnamespace App.Ext;\n\npublic static class Helpers\n{\n  public static string Slug(this Widget widget)\n  {\n    return \"s\";\n  }\n\n  public static string Tag(this Widget widget)\n  {\n    return \"t\";\n  }\n}\n",
5002            ),
5003            // Two DIFFERENT extension calls on ONE line, both naming the same
5004            // declaring static class: two guesses that serialize to the same
5005            // bytes.
5006            (
5007                "Ops/Caller.cs",
5008                "\nusing App.Ext;\nusing App.Widgets;\n\nnamespace App.Ops;\n\npublic class Caller\n{\n  public string Run()\n  {\n    Widget widget = new Widget();\n    return widget.Tag() + widget.Slug();\n  }\n}\n",
5009            ),
5010            ("Enums/Mode.cs", "namespace App.Enums { public enum Mode { On, Off } }"),
5011            // The precise counterpart: the same enum member read twice on one
5012            // line. Two identical PRECISE edges are two real occurrences in the
5013            // source, and dropping either would lose a fact -- so both survive.
5014            (
5015                "Ops/Twice.cs",
5016                "\nusing App.Enums;\n\nnamespace App.Ops;\n\npublic class Twice\n{\n  public bool Both(Mode a, Mode b)\n  {\n    return a == Mode.On && b == Mode.On;\n  }\n}\n",
5017            ),
5018        ]);
5019        let g = resolve_graph(&no_git_root(), &files);
5020        assert_eq!(
5021            heuristic_member_edges_from(&g, "Ops/Caller.cs"),
5022            vec![("App.Ext.Helpers", 12)],
5023            "the second byte-identical guess is dropped, the first kept"
5024        );
5025        assert_eq!(
5026            member_edges_from(&g, "Ops/Twice.cs"),
5027            vec![("App.Enums.Mode.On", 10), ("App.Enums.Mode.On", 10)],
5028            "the precise side is untouched: two identical rows are two occurrences, not a duplicate"
5029        );
5030        assert_eq!(
5031            g.stats.heuristic_edge_count, 1,
5032            "the dropped guess leaves the counter too"
5033        );
5034    }
5035
5036    // --- test coverage: test_methods on the merged row + the counter ---
5037
5038    /// `def()` carrying a test-method list -- the one member fact that reaches
5039    /// graph.json's def rows.
5040    fn test_def(id: &str, name: &str, ns: &str, test_methods: &[&str]) -> FragDef {
5041        FragDef {
5042            test_methods: test_methods.iter().map(|s| s.to_string()).collect(),
5043            ..def(id, name, ns, "class")
5044        }
5045    }
5046
5047    #[test]
5048    fn partial_test_class_unions_its_test_methods_across_both_declaring_files() {
5049        let files = vec![
5050            (
5051                "Tests/WidgetTests.Part1.cs".to_string(),
5052                frag(
5053                    vec![test_def(
5054                        "App.Tests.WidgetTests",
5055                        "WidgetTests",
5056                        "App.Tests",
5057                        &["Renders"],
5058                    )],
5059                    vec![],
5060                    vec![],
5061                ),
5062            ),
5063            (
5064                "Tests/WidgetTests.Part2.cs".to_string(),
5065                frag(
5066                    vec![test_def(
5067                        "App.Tests.WidgetTests",
5068                        "WidgetTests",
5069                        "App.Tests",
5070                        &["Renders", "Scales"],
5071                    )],
5072                    vec![],
5073                    vec![],
5074                ),
5075            ),
5076        ];
5077        let g = resolve_graph(&no_git_root(), &files);
5078        assert_eq!(g.defs.len(), 1);
5079        assert_eq!(
5080            g.defs[0].test_methods,
5081            vec!["Renders".to_string(), "Scales".to_string()],
5082            "union across parts, deduped, first-seen order"
5083        );
5084        assert_eq!(
5085            serde_json::to_string(&g.defs[0]).unwrap(),
5086            r#"{"id":"App.Tests.WidgetTests","name":"WidgetTests","namespace":"App.Tests","kind":"class","file":"Tests/WidgetTests.Part1.cs","line":1,"methods":[],"testMethods":["Renders","Scales"],"also_in":[{"file":"Tests/WidgetTests.Part2.cs","line":1}]}"#,
5087            "the graph ROW keeps testMethods -- between methods and also_in"
5088        );
5089    }
5090
5091    #[test]
5092    fn test_def_count_counts_merged_def_rows_not_fragment_entries() {
5093        let files = vec![
5094            (
5095                "Tests/WidgetTests.Part1.cs".to_string(),
5096                frag(
5097                    vec![test_def(
5098                        "App.Tests.WidgetTests",
5099                        "WidgetTests",
5100                        "App.Tests",
5101                        &["Renders"],
5102                    )],
5103                    vec![],
5104                    vec![],
5105                ),
5106            ),
5107            (
5108                "Tests/WidgetTests.Part2.cs".to_string(),
5109                frag(
5110                    vec![test_def(
5111                        "App.Tests.WidgetTests",
5112                        "WidgetTests",
5113                        "App.Tests",
5114                        &["Scales"],
5115                    )],
5116                    vec![],
5117                    vec![],
5118                ),
5119            ),
5120            (
5121                "Tests/CartTests.cs".to_string(),
5122                frag(
5123                    vec![test_def(
5124                        "App.Tests.CartTests",
5125                        "CartTests",
5126                        "App.Tests",
5127                        &["Places"],
5128                    )],
5129                    vec![],
5130                    vec![],
5131                ),
5132            ),
5133            (
5134                "Src/Widget.cs".to_string(),
5135                frag(
5136                    vec![def("App.Src.Widget", "Widget", "App.Src", "class")],
5137                    vec![],
5138                    vec![],
5139                ),
5140            ),
5141        ];
5142        let g = resolve_graph(&no_git_root(), &files);
5143        assert_eq!(
5144            g.stats.test_def_count, 2,
5145            "the partial class counts once across its two fragments; the production class not at all"
5146        );
5147    }
5148
5149    // --- imports: always recorded, never resolved --------------------------
5150
5151    #[test]
5152    fn imports_edge_is_recorded_regardless_of_whether_the_target_is_known() {
5153        let files = vec![(
5154            "A/Widget.cs".to_string(),
5155            frag(
5156                vec![],
5157                vec![],
5158                vec![FragRef {
5159                    kind: "imports".into(),
5160                    name: "System.Text".into(),
5161                    qualified: None,
5162                    member: None,
5163                    line: 1,
5164                    namespace: None,
5165                    type_arg_count: None,
5166                    generic: false,
5167                    receiver_type: None,
5168                    arg_count: None,
5169                    receiver_args: None,
5170                    outer_types: Vec::new(),
5171                    args: None,
5172                    receiver_property_owner: None,
5173                    receiver_call_owner: None,
5174                    receiver_call_member: None,
5175                }],
5176            ),
5177        )];
5178        let g = resolve_graph(&no_git_root(), &files);
5179        assert_eq!(g.stats.edges_by_kind.imports, 1);
5180        assert_eq!(
5181            g.stats.unresolved_external_count, 0,
5182            "imports never counts toward unresolved"
5183        );
5184    }
5185
5186    // --- stats math ---------------------------------------------------------
5187
5188    #[test]
5189    fn ambiguous_pct_only_counts_type_ref_attempts_not_uses_member_or_imports() {
5190        let files = vec![
5191            (
5192                "A/Money.cs".to_string(),
5193                frag(vec![def("A.Money", "Money", "A", "class")], vec![], vec![]),
5194            ),
5195            (
5196                "B/Money.cs".to_string(),
5197                frag(vec![def("B.Money", "Money", "B", "class")], vec![], vec![]),
5198            ),
5199            (
5200                "C/Mixed.cs".to_string(),
5201                frag(
5202                    vec![def("C.Mixed", "Mixed", "C", "class")],
5203                    vec![],
5204                    vec![
5205                        type_ref("uses-type", "Money", None, "C"), // ambiguous
5206                        FragRef {
5207                            kind: "imports".into(),
5208                            name: "System".into(),
5209                            qualified: None,
5210                            member: None,
5211                            line: 2,
5212                            namespace: None,
5213                            type_arg_count: None,
5214                            generic: false,
5215                            receiver_type: None,
5216                            arg_count: None,
5217                            receiver_args: None,
5218                            outer_types: Vec::new(),
5219                            args: None,
5220                            receiver_property_owner: None,
5221                            receiver_call_owner: None,
5222                            receiver_call_member: None,
5223                        },
5224                    ],
5225                ),
5226            ),
5227        ];
5228        let g = resolve_graph(&no_git_root(), &files);
5229        // type_ref_attempts = inherits(0) + uses-type(0) + ambiguous(1) = 1
5230        assert_eq!(g.stats.ambiguous_pct, Percent1::from_ratio(1, 1));
5231        assert_eq!(
5232            serde_json::to_string(&g.stats.ambiguous_pct).unwrap(),
5233            "100"
5234        );
5235    }
5236
5237    // --- the enclosing-type step ---
5238
5239    /// A bare type ref carrying the enclosing-type stack the extractor would
5240    /// have recorded for it.
5241    fn nested_ref(kind: &str, name: &str, ns: &str, outer: &[&str]) -> FragRef {
5242        FragRef {
5243            outer_types: outer.iter().map(|s| (*s).to_string()).collect(),
5244            ..type_ref(kind, name, None, ns)
5245        }
5246    }
5247
5248    #[test]
5249    fn v8_nested_step_resolves_a_type_declared_in_the_enclosing_type() {
5250        let files = vec![(
5251            "Core/Types.cs".to_string(),
5252            frag(
5253                vec![
5254                    def("App.Core.Outer", "Outer", "App.Core", "class"),
5255                    def("App.Core.Outer+Nested", "Nested", "App.Core", "class"),
5256                    def("App.Core.Other+Nested", "Nested", "App.Core", "class"),
5257                ],
5258                vec![],
5259                vec![nested_ref("uses-type", "Nested", "App.Core", &["Outer"])],
5260            ),
5261        )];
5262        let g = resolve_graph(&no_git_root(), &files);
5263        match find_edge(&g, |e| matches!(e, Edge::UsesType { .. })).expect("resolved edge present")
5264        {
5265            Edge::UsesType { to, .. } => assert_eq!(to, "App.Core.Outer+Nested"),
5266            _ => unreachable!(),
5267        }
5268        // Two same-named nested defs: without the stack this was ambiguous.
5269        assert_eq!(g.stats.ambiguous_count, 0);
5270    }
5271
5272    #[test]
5273    fn v8_nested_step_beats_the_namespace_and_usings_steps() {
5274        let files = vec![
5275            (
5276                "Other/Beta.cs".to_string(),
5277                frag(
5278                    vec![def("App.Other.Beta", "Beta", "App.Other", "class")],
5279                    vec![],
5280                    vec![],
5281                ),
5282            ),
5283            (
5284                "Core/Types.cs".to_string(),
5285                frag(
5286                    vec![
5287                        def("App.Core.Alpha", "Alpha", "App.Core", "class"),
5288                        def("App.Core.Outer+Alpha", "Alpha", "App.Core", "class"),
5289                        def("App.Core.Outer+Beta", "Beta", "App.Core", "class"),
5290                    ],
5291                    vec![FragUsing::Plain {
5292                        text: "App.Other".into(),
5293                        global: false,
5294                    }],
5295                    vec![
5296                        nested_ref("uses-type", "Alpha", "App.Core", &["Outer"]),
5297                        nested_ref("uses-type", "Beta", "App.Core", &["Outer"]),
5298                    ],
5299                ),
5300            ),
5301        ];
5302        let g = resolve_graph(&no_git_root(), &files);
5303        let targets: Vec<&str> = g
5304            .edges
5305            .iter()
5306            .filter_map(|e| match e {
5307                Edge::UsesType { to, .. } => Some(to.as_str()),
5308                _ => None,
5309            })
5310            .collect();
5311        assert_eq!(targets, vec!["App.Core.Outer+Alpha", "App.Core.Outer+Beta"]);
5312    }
5313
5314    #[test]
5315    fn v8_alias_still_short_circuits_above_the_nested_step() {
5316        // C# puts type scope above a using-alias; devscout keeps the alias first
5317        // by construction -- a documented deviation, pinned here.
5318        let files = vec![
5319            (
5320                "Other/Gamma.cs".to_string(),
5321                frag(
5322                    vec![def("App.Other.Gamma", "Gamma", "App.Other", "class")],
5323                    vec![],
5324                    vec![],
5325                ),
5326            ),
5327            (
5328                "Core/Types.cs".to_string(),
5329                frag(
5330                    vec![def("App.Core.Outer+Gamma", "Gamma", "App.Core", "class")],
5331                    vec![FragUsing::Alias {
5332                        alias: "Gamma".into(),
5333                        target: "App.Other.Gamma".into(),
5334                        global: false,
5335                    }],
5336                    vec![nested_ref("uses-type", "Gamma", "App.Core", &["Outer"])],
5337                ),
5338            ),
5339        ];
5340        let g = resolve_graph(&no_git_root(), &files);
5341        match find_edge(&g, |e| matches!(e, Edge::UsesType { .. })).expect("resolved edge present")
5342        {
5343            Edge::UsesType { to, .. } => assert_eq!(to, "App.Other.Gamma"),
5344            _ => unreachable!(),
5345        }
5346    }
5347
5348    #[test]
5349    fn v8_the_innermost_enclosing_type_wins_over_an_outer_one() {
5350        let files = vec![(
5351            "Core/Nest.cs".to_string(),
5352            frag(
5353                vec![
5354                    def("App.Core.Outer+Target", "Target", "App.Core", "class"),
5355                    def("App.Core.Outer+Inner+Target", "Target", "App.Core", "class"),
5356                ],
5357                vec![],
5358                vec![nested_ref(
5359                    "uses-type",
5360                    "Target",
5361                    "App.Core",
5362                    &["Outer", "Inner"],
5363                )],
5364            ),
5365        )];
5366        let g = resolve_graph(&no_git_root(), &files);
5367        match find_edge(&g, |e| matches!(e, Edge::UsesType { .. })).expect("resolved edge present")
5368        {
5369            Edge::UsesType { to, .. } => assert_eq!(to, "App.Core.Outer+Inner+Target"),
5370            _ => unreachable!(),
5371        }
5372    }
5373
5374    #[test]
5375    fn v8_a_dotted_nested_ref_never_enters_the_nested_step() {
5376        // BOUNDS: "." is not "+", and the ref text alone cannot say which was
5377        // meant, so a dotted "Outer.Nested" stays on the qualified ladder and
5378        // falls through to the global step -- ambiguous here, by design.
5379        let files = vec![(
5380            "Core/Types.cs".to_string(),
5381            frag(
5382                vec![
5383                    def("App.Core.Outer+Nested", "Nested", "App.Core", "class"),
5384                    def("App.Core.Other+Nested", "Nested", "App.Core", "class"),
5385                ],
5386                vec![],
5387                vec![FragRef {
5388                    outer_types: vec!["Outer".into()],
5389                    ..type_ref("uses-type", "Nested", Some("Outer.Nested"), "App.Core")
5390                }],
5391            ),
5392        )];
5393        let g = resolve_graph(&no_git_root(), &files);
5394        assert!(find_edge(&g, |e| matches!(e, Edge::UsesType { .. })).is_none());
5395        assert_eq!(g.stats.ambiguous_count, 1);
5396    }
5397
5398    #[test]
5399    fn v8_an_outer_types_naming_no_nested_id_falls_through_unchanged() {
5400        let files = vec![
5401            (
5402                "Other/Marker.cs".to_string(),
5403                frag(
5404                    vec![def("App.Other.Marker", "Marker", "App.Other", "class")],
5405                    vec![],
5406                    vec![],
5407                ),
5408            ),
5409            (
5410                "Core/Types.cs".to_string(),
5411                frag(
5412                    vec![def("App.Core.Outer", "Outer", "App.Core", "class")],
5413                    vec![FragUsing::Plain {
5414                        text: "App.Other".into(),
5415                        global: false,
5416                    }],
5417                    vec![nested_ref("uses-type", "Marker", "App.Core", &["Outer"])],
5418                ),
5419            ),
5420        ];
5421        let g = resolve_graph(&no_git_root(), &files);
5422        match find_edge(&g, |e| matches!(e, Edge::UsesType { .. })).expect("resolved edge present")
5423        {
5424            Edge::UsesType { to, .. } => assert_eq!(to, "App.Other.Marker"),
5425            _ => unreachable!(),
5426        }
5427    }
5428
5429    #[test]
5430    fn v8_tier_e_receiver_probe_carries_the_refs_outer_types() {
5431        // The whole defect: without the stack on the SYNTHETIC probe the
5432        // member access resolves against two same-named nested types and can
5433        // only ever be a guess.
5434        let files = vec![(
5435            "Core/Types.cs".to_string(),
5436            frag(
5437                vec![
5438                    def("App.Core.Outer", "Outer", "App.Core", "class"),
5439                    def_with(
5440                        "App.Core.Outer+Nested",
5441                        "Nested",
5442                        "App.Core",
5443                        "class",
5444                        &["Run"],
5445                        &[],
5446                        &[],
5447                    ),
5448                    def_with(
5449                        "App.Core.Other+Nested",
5450                        "Nested",
5451                        "App.Core",
5452                        "class",
5453                        &["Run"],
5454                        &[],
5455                        &[],
5456                    ),
5457                ],
5458                vec![],
5459                vec![FragRef {
5460                    outer_types: vec!["Outer".into()],
5461                    ..receiver_ref("_n", "Run", "App.Core", "Nested", Some(0))
5462                }],
5463            ),
5464        )];
5465        let g = resolve_graph(&no_git_root(), &files);
5466        let precise: Vec<&str> = g
5467            .edges
5468            .iter()
5469            .filter_map(|e| match e {
5470                Edge::UsesMember {
5471                    to,
5472                    heuristic: false,
5473                    ..
5474                } => Some(to.as_str()),
5475                _ => None,
5476            })
5477            .collect();
5478        assert_eq!(precise, vec!["App.Core.Outer+Nested"]);
5479        assert_eq!(g.stats.heuristic_edge_count, 0);
5480    }
5481
5482    // --- constructor-parameter DI resolution ---
5483    //
5484    // These tests cover the RESOLVED 'ctor-di' edge `resolve_graph` produces
5485    // from the extraction-layer facts (a def's type_params/base_generic_args and
5486    // the 'ctor-param' ref itself).
5487
5488    fn def_with_bases_and_generics(
5489        id: &str,
5490        name: &str,
5491        ns: &str,
5492        bases: &[&str],
5493        type_params: &[&str],
5494        base_generic_args: &[(&str, &[&str])],
5495    ) -> FragDef {
5496        let mut bga = crate::graph::OrderedMap::new();
5497        for (k, v) in base_generic_args {
5498            bga.insert((*k).to_string(), v.iter().map(|s| s.to_string()).collect());
5499        }
5500        FragDef {
5501            bases: bases.iter().map(|s| s.to_string()).collect(),
5502            type_params: type_params.iter().map(|s| s.to_string()).collect(),
5503            base_generic_args: bga,
5504            ..def(id, name, ns, "class")
5505        }
5506    }
5507
5508    fn ctor_param_ref(name: &str, ns: &str, args: Option<Vec<String>>) -> FragRef {
5509        FragRef {
5510            args,
5511            ..type_ref("ctor-param", name, None, ns)
5512        }
5513    }
5514
5515    fn ctor_di_edges<'a>(g: &'a Graph, iface: &str) -> Vec<&'a Edge> {
5516        g.edges
5517            .iter()
5518            .filter(|e| matches!(e, Edge::CtorDi { iface: i, .. } if i == iface))
5519            .collect()
5520    }
5521
5522    #[test]
5523    fn ctor_di_a_closed_generic_ctor_param_resolves_to_the_open_generic_implementation_that_passes_its_type_argument_through(
5524    ) {
5525        let files = vec![
5526            (
5527                "Di/IRepository.cs".to_string(),
5528                frag(
5529                    vec![def(
5530                        "App.Di.IRepository",
5531                        "IRepository",
5532                        "App.Di",
5533                        "interface",
5534                    )],
5535                    vec![],
5536                    vec![],
5537                ),
5538            ),
5539            (
5540                "Di/MongoRepository.cs".to_string(),
5541                frag(
5542                    vec![def_with_bases_and_generics(
5543                        "App.Di.MongoRepository",
5544                        "MongoRepository",
5545                        "App.Di",
5546                        &["IRepository"],
5547                        &["T"],
5548                        &[("IRepository", &["*"])],
5549                    )],
5550                    vec![],
5551                    vec![],
5552                ),
5553            ),
5554            (
5555                "Di/User.cs".to_string(),
5556                frag(
5557                    vec![def("App.Di.User", "User", "App.Di", "class")],
5558                    vec![],
5559                    vec![],
5560                ),
5561            ),
5562            (
5563                "Di/Controller.cs".to_string(),
5564                frag(
5565                    vec![def("App.Di.Controller", "Controller", "App.Di", "class")],
5566                    vec![],
5567                    vec![ctor_param_ref(
5568                        "IRepository",
5569                        "App.Di",
5570                        Some(vec!["User".to_string()]),
5571                    )],
5572                ),
5573            ),
5574        ];
5575        let g = resolve_graph(&no_git_root(), &files);
5576        let edges = ctor_di_edges(&g, "IRepository");
5577        assert_eq!(edges.len(), 1);
5578        match edges[0] {
5579            Edge::CtorDi {
5580                resolution,
5581                args,
5582                to,
5583                candidates,
5584                ..
5585            } => {
5586                assert_eq!(resolution, "open-generic");
5587                assert_eq!(args.as_deref(), Some(&["User".to_string()][..]));
5588                assert_eq!(to.as_deref(), Some("App.Di.MongoRepository"));
5589                assert!(candidates.is_empty());
5590            }
5591            _ => unreachable!(),
5592        }
5593    }
5594
5595    #[test]
5596    fn ctor_di_a_plain_non_generic_ctor_param_resolves_to_its_sole_implementor() {
5597        let files = vec![
5598            (
5599                "Di/IFooService.cs".to_string(),
5600                frag(
5601                    vec![def(
5602                        "App.Di.IFooService",
5603                        "IFooService",
5604                        "App.Di",
5605                        "interface",
5606                    )],
5607                    vec![],
5608                    vec![],
5609                ),
5610            ),
5611            (
5612                "Di/FooService.cs".to_string(),
5613                frag(
5614                    vec![def_with_bases_and_generics(
5615                        "App.Di.FooService",
5616                        "FooService",
5617                        "App.Di",
5618                        &["IFooService"],
5619                        &[],
5620                        &[],
5621                    )],
5622                    vec![],
5623                    vec![],
5624                ),
5625            ),
5626            (
5627                "Di/Controller.cs".to_string(),
5628                frag(
5629                    vec![def("App.Di.Controller", "Controller", "App.Di", "class")],
5630                    vec![],
5631                    vec![ctor_param_ref("IFooService", "App.Di", None)],
5632                ),
5633            ),
5634        ];
5635        let g = resolve_graph(&no_git_root(), &files);
5636        let edges = ctor_di_edges(&g, "IFooService");
5637        assert_eq!(edges.len(), 1);
5638        match edges[0] {
5639            Edge::CtorDi {
5640                resolution,
5641                args,
5642                to,
5643                ..
5644            } => {
5645                assert_eq!(resolution, "plain");
5646                assert_eq!(
5647                    *args, None,
5648                    "a non-generic ctor param carries no args field at all"
5649                );
5650                assert_eq!(to.as_deref(), Some("App.Di.FooService"));
5651            }
5652            _ => unreachable!(),
5653        }
5654    }
5655
5656    #[test]
5657    fn ctor_di_a_ctor_param_type_absent_from_the_corpus_is_classified_infra_when_the_file_imports_a_bcl_namespace(
5658    ) {
5659        let files = vec![(
5660            "Di/Controller.cs".to_string(),
5661            frag(
5662                vec![def("App.Di.Controller", "Controller", "App.Di", "class")],
5663                vec![FragUsing::Plain {
5664                    text: "Microsoft.Extensions.Logging".into(),
5665                    global: false,
5666                }],
5667                vec![ctor_param_ref(
5668                    "ILogger",
5669                    "App.Di",
5670                    Some(vec!["Controller".to_string()]),
5671                )],
5672            ),
5673        )];
5674        let g = resolve_graph(&no_git_root(), &files);
5675        let edges = ctor_di_edges(&g, "ILogger");
5676        assert_eq!(edges.len(), 1);
5677        match edges[0] {
5678            Edge::CtorDi {
5679                resolution,
5680                args,
5681                to,
5682                ..
5683            } => {
5684                assert_eq!(resolution, "infra");
5685                assert_eq!(args.as_deref(), Some(&["Controller".to_string()][..]));
5686                assert_eq!(*to, None);
5687            }
5688            _ => unreachable!(),
5689        }
5690    }
5691
5692    #[test]
5693    fn ctor_di_an_unresolvable_ctor_param_with_no_bcl_using_in_scope_is_unresolved_not_dropped() {
5694        let files = vec![(
5695            "Di/Controller.cs".to_string(),
5696            frag(
5697                vec![def("App.Di.Controller", "Controller", "App.Di", "class")],
5698                vec![],
5699                vec![ctor_param_ref("ISomeThirdPartyThing", "App.Di", None)],
5700            ),
5701        )];
5702        let g = resolve_graph(&no_git_root(), &files);
5703        let edges = ctor_di_edges(&g, "ISomeThirdPartyThing");
5704        assert_eq!(edges.len(), 1);
5705        match edges[0] {
5706            Edge::CtorDi { resolution, .. } => assert_eq!(resolution, "unresolved"),
5707            _ => unreachable!(),
5708        }
5709    }
5710
5711    #[test]
5712    fn ctor_di_two_implementors_tied_at_the_same_precedence_tier_are_ambiguous_never_guessed() {
5713        let files = vec![
5714            (
5715                "Di/IFooService.cs".to_string(),
5716                frag(
5717                    vec![def(
5718                        "App.Di.IFooService",
5719                        "IFooService",
5720                        "App.Di",
5721                        "interface",
5722                    )],
5723                    vec![],
5724                    vec![],
5725                ),
5726            ),
5727            (
5728                "Di/FooServiceA.cs".to_string(),
5729                frag(
5730                    vec![def_with_bases_and_generics(
5731                        "App.Di.FooServiceA",
5732                        "FooServiceA",
5733                        "App.Di",
5734                        &["IFooService"],
5735                        &[],
5736                        &[],
5737                    )],
5738                    vec![],
5739                    vec![],
5740                ),
5741            ),
5742            (
5743                "Di/FooServiceB.cs".to_string(),
5744                frag(
5745                    vec![def_with_bases_and_generics(
5746                        "App.Di.FooServiceB",
5747                        "FooServiceB",
5748                        "App.Di",
5749                        &["IFooService"],
5750                        &[],
5751                        &[],
5752                    )],
5753                    vec![],
5754                    vec![],
5755                ),
5756            ),
5757            (
5758                "Di/Controller.cs".to_string(),
5759                frag(
5760                    vec![def("App.Di.Controller", "Controller", "App.Di", "class")],
5761                    vec![],
5762                    vec![ctor_param_ref("IFooService", "App.Di", None)],
5763                ),
5764            ),
5765        ];
5766        let g = resolve_graph(&no_git_root(), &files);
5767        let edges = ctor_di_edges(&g, "IFooService");
5768        assert_eq!(edges.len(), 1);
5769        match edges[0] {
5770            Edge::CtorDi {
5771                resolution,
5772                candidates,
5773                ..
5774            } => {
5775                assert_eq!(resolution, "ambiguous");
5776                assert_eq!(
5777                    candidates.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
5778                    vec!["App.Di.FooServiceA", "App.Di.FooServiceB"]
5779                );
5780            }
5781            _ => unreachable!(),
5782        }
5783    }
5784
5785    #[test]
5786    fn ctor_di_a_closed_implementor_is_preferred_over_an_open_generic_one_when_both_exist() {
5787        let files = vec![
5788            (
5789                "Di/IRepository.cs".to_string(),
5790                frag(
5791                    vec![def(
5792                        "App.Di.IRepository",
5793                        "IRepository",
5794                        "App.Di",
5795                        "interface",
5796                    )],
5797                    vec![],
5798                    vec![],
5799                ),
5800            ),
5801            (
5802                "Di/MongoRepository.cs".to_string(),
5803                frag(
5804                    vec![def_with_bases_and_generics(
5805                        "App.Di.MongoRepository",
5806                        "MongoRepository",
5807                        "App.Di",
5808                        &["IRepository"],
5809                        &["T"],
5810                        &[("IRepository", &["*"])],
5811                    )],
5812                    vec![],
5813                    vec![],
5814                ),
5815            ),
5816            (
5817                "Di/SpecificUserRepository.cs".to_string(),
5818                frag(
5819                    vec![def_with_bases_and_generics(
5820                        "App.Di.SpecificUserRepository",
5821                        "SpecificUserRepository",
5822                        "App.Di",
5823                        &["IRepository"],
5824                        &[],
5825                        &[("IRepository", &["User"])],
5826                    )],
5827                    vec![],
5828                    vec![],
5829                ),
5830            ),
5831            (
5832                "Di/User.cs".to_string(),
5833                frag(
5834                    vec![def("App.Di.User", "User", "App.Di", "class")],
5835                    vec![],
5836                    vec![],
5837                ),
5838            ),
5839            (
5840                "Di/Controller.cs".to_string(),
5841                frag(
5842                    vec![def("App.Di.Controller", "Controller", "App.Di", "class")],
5843                    vec![],
5844                    vec![ctor_param_ref(
5845                        "IRepository",
5846                        "App.Di",
5847                        Some(vec!["User".to_string()]),
5848                    )],
5849                ),
5850            ),
5851        ];
5852        let g = resolve_graph(&no_git_root(), &files);
5853        let edges = ctor_di_edges(&g, "IRepository");
5854        assert_eq!(edges.len(), 1);
5855        match edges[0] {
5856            Edge::CtorDi { resolution, to, .. } => {
5857                assert_eq!(resolution, "closed", "the non-generic, exactly-matching implementor wins over the open-generic passthrough");
5858                assert_eq!(to.as_deref(), Some("App.Di.SpecificUserRepository"));
5859            }
5860            _ => unreachable!(),
5861        }
5862    }
5863
5864    // --- the property hop and the call hop ---
5865
5866    /// The two member->type maps the hops read, on top of `def_with`'s member
5867    /// lists: (method, return type) and (property, declared type).
5868    fn with_member_types(
5869        base: FragDef,
5870        method_returns: &[(&str, &str)],
5871        property_types: &[(&str, &str)],
5872    ) -> FragDef {
5873        let mut returns = OrderedMap::new();
5874        for (name, ty) in method_returns {
5875            returns.insert((*name).to_string(), (*ty).to_string());
5876        }
5877        let mut properties = OrderedMap::new();
5878        for (name, ty) in property_types {
5879            properties.insert(
5880                (*name).to_string(),
5881                FragFact {
5882                    type_name: (*ty).to_string(),
5883                    args: None,
5884                },
5885            );
5886        }
5887        FragDef {
5888            method_returns: returns,
5889            property_types: properties,
5890            ..base
5891        }
5892    }
5893
5894    /// The TAIL window of a two-segment chain, carrying the head type the
5895    /// extractor recorded for it: `head.<property>.<member>()`.
5896    fn property_hop_ref(owner: &str, property: &str, member: &str, ns: &str) -> FragRef {
5897        FragRef {
5898            receiver_property_owner: Some(owner.into()),
5899            ..member_ref(property, Some(&format!("head.{property}")), member, ns)
5900        }
5901    }
5902
5903    /// A bare-qualifier member ref whose qualifier is a
5904    /// `var x = Owner.Callee(...)` local.
5905    fn call_receiver_ref(name: &str, owner: &str, callee: &str, member: &str, ns: &str) -> FragRef {
5906        FragRef {
5907            receiver_call_owner: Some(owner.into()),
5908            receiver_call_member: Some(callee.into()),
5909            ..member_ref(name, None, member, ns)
5910        }
5911    }
5912
5913    #[test]
5914    fn ds0012_property_hop_resolves_to_the_propertys_declared_type() {
5915        let files = vec![
5916            (
5917                "Other/Settings.cs".to_string(),
5918                frag(
5919                    vec![def_with(
5920                        "App.Other.Settings",
5921                        "Settings",
5922                        "App.Other",
5923                        "class",
5924                        &["Reload"],
5925                        &[],
5926                        &[],
5927                    )],
5928                    vec![],
5929                    vec![],
5930                ),
5931            ),
5932            (
5933                "Other/Widget.cs".to_string(),
5934                frag(
5935                    vec![with_member_types(
5936                        def_with(
5937                            "App.Other.Widget",
5938                            "Widget",
5939                            "App.Other",
5940                            "class",
5941                            &[],
5942                            &["Config"],
5943                            &[],
5944                        ),
5945                        &[],
5946                        &[("Config", "Settings")],
5947                    )],
5948                    vec![],
5949                    vec![],
5950                ),
5951            ),
5952            (
5953                "Consumers/Hop.cs".to_string(),
5954                frag(
5955                    vec![def("App.Consumers.Hop", "Hop", "App.Consumers", "class")],
5956                    vec![FragUsing::Plain {
5957                        text: "App.Other".to_string(),
5958                        global: false,
5959                    }],
5960                    vec![property_hop_ref(
5961                        "Widget",
5962                        "Config",
5963                        "Reload",
5964                        "App.Consumers",
5965                    )],
5966                ),
5967            ),
5968        ];
5969        let g = resolve_graph(&no_git_root(), &files);
5970        assert_eq!(member_edge_targets(&g), vec!["App.Other.Settings"]);
5971        assert_eq!(
5972            g.stats.heuristic_edge_count, 0,
5973            "a precise hop leaves nothing for the scored tier"
5974        );
5975    }
5976
5977    #[test]
5978    fn ds0012_property_hop_stops_on_an_unrecorded_property_a_missing_member_and_an_ambiguous_type()
5979    {
5980        let files = vec![
5981            (
5982                "Other/Settings.cs".to_string(),
5983                frag(
5984                    vec![def_with(
5985                        "App.Other.Settings",
5986                        "Settings",
5987                        "App.Other",
5988                        "class",
5989                        &["Reload"],
5990                        &[],
5991                        &[],
5992                    )],
5993                    vec![],
5994                    vec![],
5995                ),
5996            ),
5997            (
5998                "Other/Widget.cs".to_string(),
5999                frag(
6000                    vec![with_member_types(
6001                        def_with(
6002                            "App.Other.Widget",
6003                            "Widget",
6004                            "App.Other",
6005                            "class",
6006                            &[],
6007                            &["Label", "Config", "Price"],
6008                            &[],
6009                        ),
6010                        &[],
6011                        // `Label` is declared `string`: a predefined type
6012                        // records no fact at all, so it is absent here.
6013                        &[("Config", "Settings"), ("Price", "Money")],
6014                    )],
6015                    vec![],
6016                    vec![],
6017                ),
6018            ),
6019            (
6020                "Money/A.cs".to_string(),
6021                frag(
6022                    vec![def_with(
6023                        "App.Money.A.Money",
6024                        "Money",
6025                        "App.Money.A",
6026                        "class",
6027                        &["Round"],
6028                        &[],
6029                        &[],
6030                    )],
6031                    vec![],
6032                    vec![],
6033                ),
6034            ),
6035            (
6036                "Money/B.cs".to_string(),
6037                frag(
6038                    vec![def_with(
6039                        "App.Money.B.Money",
6040                        "Money",
6041                        "App.Money.B",
6042                        "class",
6043                        &["Round"],
6044                        &[],
6045                        &[],
6046                    )],
6047                    vec![],
6048                    vec![],
6049                ),
6050            ),
6051            (
6052                "Consumers/Stops.cs".to_string(),
6053                frag(
6054                    vec![def(
6055                        "App.Consumers.Stops",
6056                        "Stops",
6057                        "App.Consumers",
6058                        "class",
6059                    )],
6060                    vec![
6061                        FragUsing::Plain {
6062                            text: "App.Other".to_string(),
6063                            global: false,
6064                        },
6065                        FragUsing::Plain {
6066                            text: "App.Money.A".to_string(),
6067                            global: false,
6068                        },
6069                        FragUsing::Plain {
6070                            text: "App.Money.B".to_string(),
6071                            global: false,
6072                        },
6073                    ],
6074                    vec![
6075                        property_hop_ref("Widget", "Label", "Trim", "App.Consumers"),
6076                        property_hop_ref("Widget", "Config", "Missing", "App.Consumers"),
6077                        property_hop_ref("Widget", "Price", "Round", "App.Consumers"),
6078                    ],
6079                ),
6080            ),
6081        ];
6082        let g = resolve_graph(&no_git_root(), &files);
6083        assert!(
6084            member_edge_targets(&g).is_empty(),
6085            "no recorded type, no declared member, and an ambiguous type each end the hop"
6086        );
6087    }
6088
6089    #[test]
6090    fn ds0010_var_from_invocation_resolves_through_the_callees_recorded_return_type() {
6091        let files = vec![
6092            (
6093                "Other/Widget.cs".to_string(),
6094                frag(
6095                    vec![def_with(
6096                        "App.Other.Widget",
6097                        "Widget",
6098                        "App.Other",
6099                        "class",
6100                        &["Render"],
6101                        &[],
6102                        &[],
6103                    )],
6104                    vec![],
6105                    vec![],
6106                ),
6107            ),
6108            (
6109                "Other/Factory.cs".to_string(),
6110                frag(
6111                    vec![with_member_types(
6112                        def_with(
6113                            "App.Other.Factory",
6114                            "Factory",
6115                            "App.Other",
6116                            "class",
6117                            &["Make"],
6118                            &[],
6119                            &[],
6120                        ),
6121                        &[("Make", "Widget")],
6122                        &[],
6123                    )],
6124                    vec![],
6125                    vec![],
6126                ),
6127            ),
6128            (
6129                "Consumers/FromCall.cs".to_string(),
6130                frag(
6131                    vec![def(
6132                        "App.Consumers.FromCall",
6133                        "FromCall",
6134                        "App.Consumers",
6135                        "class",
6136                    )],
6137                    vec![FragUsing::Plain {
6138                        text: "App.Other".to_string(),
6139                        global: false,
6140                    }],
6141                    vec![call_receiver_ref(
6142                        "made",
6143                        "Factory",
6144                        "Make",
6145                        "Render",
6146                        "App.Consumers",
6147                    )],
6148                ),
6149            ),
6150        ];
6151        let g = resolve_graph(&no_git_root(), &files);
6152        assert_eq!(member_edge_targets(&g), vec!["App.Other.Widget"]);
6153    }
6154
6155    #[test]
6156    fn ds0010_ambiguous_out_of_graph_and_return_less_callees_stay_taken_but_unknown() {
6157        let files = vec![
6158            (
6159                "Other/Widget.cs".to_string(),
6160                frag(
6161                    vec![def_with(
6162                        "App.Other.Widget",
6163                        "Widget",
6164                        "App.Other",
6165                        "class",
6166                        &["Render"],
6167                        &[],
6168                        &[],
6169                    )],
6170                    vec![],
6171                    vec![],
6172                ),
6173            ),
6174            (
6175                "A/Factory.cs".to_string(),
6176                frag(
6177                    vec![with_member_types(
6178                        def_with(
6179                            "App.A.Factory",
6180                            "Factory",
6181                            "App.A",
6182                            "class",
6183                            &["Make"],
6184                            &[],
6185                            &[],
6186                        ),
6187                        &[("Make", "Widget")],
6188                        &[],
6189                    )],
6190                    vec![],
6191                    vec![],
6192                ),
6193            ),
6194            (
6195                "B/Factory.cs".to_string(),
6196                frag(
6197                    vec![with_member_types(
6198                        def_with(
6199                            "App.B.Factory",
6200                            "Factory",
6201                            "App.B",
6202                            "class",
6203                            &["Make"],
6204                            &[],
6205                            &[],
6206                        ),
6207                        &[("Make", "Widget")],
6208                        &[],
6209                    )],
6210                    vec![],
6211                    vec![],
6212                ),
6213            ),
6214            (
6215                "Other/Silent.cs".to_string(),
6216                frag(
6217                    vec![def_with(
6218                        "App.Other.Silent",
6219                        "Silent",
6220                        "App.Other",
6221                        "class",
6222                        &["Make"],
6223                        &[],
6224                        &[],
6225                    )],
6226                    vec![],
6227                    vec![],
6228                ),
6229            ),
6230            (
6231                "Consumers/Unknowns.cs".to_string(),
6232                frag(
6233                    vec![def(
6234                        "App.Consumers.Unknowns",
6235                        "Unknowns",
6236                        "App.Consumers",
6237                        "class",
6238                    )],
6239                    vec![
6240                        FragUsing::Plain {
6241                            text: "App.Other".to_string(),
6242                            global: false,
6243                        },
6244                        FragUsing::Plain {
6245                            text: "App.A".to_string(),
6246                            global: false,
6247                        },
6248                        FragUsing::Plain {
6249                            text: "App.B".to_string(),
6250                            global: false,
6251                        },
6252                    ],
6253                    vec![
6254                        call_receiver_ref(
6255                            "ambiguous",
6256                            "Factory",
6257                            "Make",
6258                            "Render",
6259                            "App.Consumers",
6260                        ),
6261                        call_receiver_ref(
6262                            "external",
6263                            "ThirdParty",
6264                            "Make",
6265                            "Render",
6266                            "App.Consumers",
6267                        ),
6268                        // `Silent.Make` is declared but records no return type
6269                        // (a void method blocks its own name).
6270                        call_receiver_ref("silent", "Silent", "Make", "Render", "App.Consumers"),
6271                    ],
6272                ),
6273            ),
6274        ];
6275        let g = resolve_graph(&no_git_root(), &files);
6276        // An owner the ladder refuses to pick, an owner it never finds, and an
6277        // owner whose `Make` records no return type all leave the local exactly
6278        // as unknown as the extractor left it.
6279        assert!(member_edge_targets(&g).is_empty());
6280    }
6281}