1use 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
46const SCORED_UNIQUENESS_CAP: usize = 3;
52const SCORED_EMIT_CAP: usize = 3;
53
54pub struct DefIndex {
64 pub defs: Vec<Def>,
66 pub member_lists: Vec<MemberLists>,
73 pub qualified_name_to_def: HashMap<String, usize>,
75 pub qualified_name_and_arity_to_def: HashMap<(String, usize), usize>,
77 pub simple_name_to_defs: HashMap<String, Vec<usize>>,
79 pub extension_index: HashMap<String, Vec<ExtCandidate>>,
97 pub member_name_to_defs: HashMap<String, Vec<usize>>,
109}
110
111pub struct ExtCandidate {
114 pub def_idx: usize,
116 pub entry: FragExtensionMethod,
118}
119
120#[derive(Default)]
123pub struct MemberLists {
124 pub properties: Vec<String>,
126 pub fields: Vec<String>,
128 pub extension_methods: Vec<(String, String, usize, i64)>,
135 pub bases: Vec<String>,
138 pub type_params: Vec<String>,
144 pub base_generic_args: Vec<(String, Vec<String>)>,
148 pub method_returns: OrderedMap<String>,
153 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 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 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 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 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 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 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
357fn 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
383fn 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
396fn 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
415fn 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
431struct FileContext {
433 usings: HashSet<String>,
434 aliases: HashMap<String, String>,
435}
436
437fn 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
466fn 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 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
556fn 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
562fn 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
580fn 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 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#[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
651fn 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 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 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 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 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 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 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 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
795fn 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
820fn 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
841fn is_infra_namespace(name: &str) -> bool {
847 name == "System"
848 || name == "Microsoft"
849 || name.starts_with("System.")
850 || name.starts_with("Microsoft.")
851}
852
853enum CtorDiResolution {
856 Plain(usize),
858 Closed(usize),
861 OpenGeneric(usize),
865 Ambiguous(Vec<usize>),
868 Infra,
872 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 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
969fn 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
995fn 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
1027pub fn resolve_graph(root: &Path, fragments_by_file: &[(String, Fragment)]) -> Graph {
1034 resolve_graph_with_ts(root, fragments_by_file, &[])
1035}
1036
1037pub 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 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 let mut heuristic_edge_count: usize = 0;
1067
1068 for (file, frag) in fragments_by_file {
1069 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 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 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 let mut receiver_def: Option<usize> = None;
1188 let mut receiver_result: Option<Resolution> = None;
1189 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 emitted = true;
1234 }
1235 }
1236 receiver_result = Some(rr);
1237 }
1238 }
1239 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 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 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 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 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 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 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 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 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 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 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 #[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 #[test]
1901 fn alias_wins_over_an_otherwise_ambiguous_simple_name() {
1902 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 (
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 #[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 #[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 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")], ),
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 assert_eq!(g.stats.ambiguous_count, 0);
2264 assert_eq!(g.stats.unresolved_external_count, 0);
2265 }
2266
2267 #[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 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 #[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 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 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 #[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 #[test]
2752 fn qualified_reference_resolves_at_an_outer_enclosing_namespace_prefix() {
2753 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 #[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 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 #[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 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 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 #[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 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 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 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 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 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 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 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 assert_eq!(heuristic_member_edge_targets(&g), vec!["App.Other.Widget"]);
3640 }
3641
3642 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 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 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 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 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 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 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 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 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 "\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 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 #[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 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 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 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 #[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 (
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 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 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 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 #[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 ("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 ("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 ("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 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 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 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 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 #[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 #[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 ("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 (
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 (
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 (
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 (
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 (
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 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 #[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 #[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"), 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 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 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 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 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 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 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 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 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 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 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 &[("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 call_receiver_ref("silent", "Silent", "Make", "Render", "App.Consumers"),
6271 ],
6272 ),
6273 ),
6274 ];
6275 let g = resolve_graph(&no_git_root(), &files);
6276 assert!(member_edge_targets(&g).is_empty());
6280 }
6281}