1use std::collections::{HashMap, HashSet};
51use std::path::Path;
52
53use crate::graph;
54use crate::manifest;
55use crate::suggest::kind_rank;
56
57#[allow(dead_code)]
62const REF_KINDS: [&str; 3] = ["inherits", "uses-type", "uses-member"];
63
64pub const DEFAULT_CAP: usize = 50;
67pub const INBOUND_CAP: usize = 30;
70pub const OUTBOUND_CAP: usize = 30;
74pub const SOURCE_MAX: usize = 120;
78pub const DEFAULT_HOPS: u32 = 2;
80
81pub const DEFAULT_IFACE_MAX_FANIN: usize = 8;
88
89pub const DEFAULT_HUB_MAX_INDEGREE: usize = 34;
100
101const INFRA_BASENAMES: [&str; 2] = ["program.cs", "startup.cs"];
110const INFRA_SUFFIXES: [&str; 8] = [
111 "serviceextensions.cs",
112 "servicecollectionextensions.cs",
113 "registration.cs",
114 "testbase.cs",
115 "testsbase.cs",
116 "basetest.cs",
117 "basetests.cs",
118 "basefixture.cs",
119];
120const INFRA_DIR: &str = "dependencyresolution/";
121const COMPOSITION_ROOT: &str = "compositionroot";
122
123pub fn is_infra_file(file: &str) -> bool {
128 let lower = file.to_lowercase();
129 let base = match lower.rfind('/') {
130 Some(i) => &lower[i + 1..],
131 None => &lower[..],
132 };
133 if INFRA_BASENAMES.contains(&base) {
134 return true;
135 }
136 if INFRA_SUFFIXES.iter().any(|sfx| lower.ends_with(sfx)) {
137 return true;
138 }
139 if lower.starts_with(INFRA_DIR) || lower.contains(&format!("/{INFRA_DIR}")) {
140 return true;
141 }
142 if let Some(at) = base.find(COMPOSITION_ROOT) {
145 if base.ends_with(".cs") {
146 let middle = &base[at + COMPOSITION_ROOT.len()..base.len() - 3];
147 if middle.chars().all(|c| c.is_ascii_alphanumeric()) {
148 return true;
149 }
150 }
151 }
152 false
153}
154pub const DEFAULT_DAMPING: f64 = 0.85;
156pub const DEFAULT_ITERATIONS: u32 = 20;
158
159pub fn source_line(root: &Path, file: &str, line: usize) -> String {
168 let Ok(body) = std::fs::read_to_string(root.join(file)) else {
169 return String::new();
170 };
171 let lines: Vec<&str> = body.split('\n').collect();
172 if line < 1 || line > lines.len() {
173 return String::new();
174 }
175 let mut raw = lines[line - 1];
176 if line == 1 {
179 raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
180 }
181 trim_source(raw).to_string()
182}
183
184pub fn find_names<'g>(graph: &'g graph::Graph, query: &str) -> Vec<&'g graph::GraphName> {
190 let tokens: Vec<String> = query
191 .to_lowercase()
192 .split_whitespace()
193 .map(String::from)
194 .collect();
195 if tokens.is_empty() {
196 return Vec::new();
197 }
198 graph
199 .names
200 .iter()
201 .filter(|n| {
202 let hay = n.name.to_lowercase();
203 tokens.iter().all(|t| hay.contains(t.as_str()))
204 })
205 .collect()
206}
207
208pub fn first_decl_line_by_file(graph: &graph::Graph) -> HashMap<String, usize> {
216 let mut lines: HashMap<String, usize> = HashMap::new();
217 for n in &graph.names {
218 lines
219 .entry(n.file.clone())
220 .and_modify(|line| *line = (*line).min(n.line))
221 .or_insert(n.line);
222 }
223 lines
224}
225
226pub fn file_inbound_counts(graph: &graph::Graph) -> HashMap<String, usize> {
244 let mut counts: HashMap<String, usize> = HashMap::new();
245 for e in &graph.edges {
246 let (from_file, to_file) = match e {
247 graph::Edge::Inherits {
248 from_file,
249 to_file,
250 heuristic,
251 ..
252 }
253 | graph::Edge::UsesType {
254 from_file,
255 to_file,
256 heuristic,
257 ..
258 }
259 | graph::Edge::UsesMember {
260 from_file,
261 to_file,
262 heuristic,
263 ..
264 } => {
265 if *heuristic {
266 continue;
267 }
268 (from_file, to_file)
269 }
270 graph::Edge::Call {
271 from_file, to_file, ..
272 }
273 | graph::Edge::JsxUse {
274 from_file, to_file, ..
275 }
276 | graph::Edge::Dispatch {
277 from_file, to_file, ..
278 } => (from_file, to_file),
279 graph::Edge::Imports { .. }
282 | graph::Edge::Import { .. }
283 | graph::Edge::CtorDi { .. }
284 | graph::Edge::Ambiguous { .. } => continue,
285 };
286 if from_file == to_file {
287 continue;
288 }
289 *counts.entry(to_file.clone()).or_insert(0) += 1;
290 }
291 counts
292}
293
294pub fn name_tier(kind: &str) -> u8 {
301 let rank = kind_rank(kind);
302 if rank <= 1 {
303 1
304 } else {
305 rank
306 }
307}
308
309#[derive(Debug, Clone)]
317pub struct SeqSet<T: Eq + std::hash::Hash + Clone> {
318 order: Vec<T>,
319 seen: HashSet<T>,
320}
321
322impl<T: Eq + std::hash::Hash + Clone> SeqSet<T> {
323 pub fn new() -> Self {
325 Self {
326 order: Vec::new(),
327 seen: HashSet::new(),
328 }
329 }
330 pub fn insert(&mut self, value: T) {
332 if self.seen.insert(value.clone()) {
333 self.order.push(value);
334 }
335 }
336 pub fn contains(&self, value: &T) -> bool {
338 self.seen.contains(value)
339 }
340 pub fn len(&self) -> usize {
342 self.order.len()
343 }
344 pub fn iter(&self) -> std::slice::Iter<'_, T> {
346 self.order.iter()
347 }
348 pub fn into_vec(self) -> Vec<T> {
350 self.order
351 }
352}
353
354impl<T: Eq + std::hash::Hash + Clone> Default for SeqSet<T> {
355 fn default() -> Self {
356 SeqSet::new()
357 }
358}
359
360#[derive(Debug, Clone)]
366pub struct SeqMap<V> {
367 entries: Vec<(String, V)>,
368 index: HashMap<String, usize>,
369}
370
371impl<V> SeqMap<V> {
372 pub fn new() -> Self {
374 Self {
375 entries: Vec::new(),
376 index: HashMap::new(),
377 }
378 }
379 pub fn contains_key(&self, key: &str) -> bool {
381 self.index.contains_key(key)
382 }
383 pub fn insert(&mut self, key: String, value: V) {
385 match self.index.get(&key) {
386 Some(&i) => self.entries[i].1 = value,
387 None => {
388 self.index.insert(key.clone(), self.entries.len());
389 self.entries.push((key, value));
390 }
391 }
392 }
393 pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
395 self.entries.iter().map(|(k, v)| (k, v))
396 }
397 pub fn keys(&self) -> impl Iterator<Item = &String> {
399 self.entries.iter().map(|(k, _)| k)
400 }
401}
402
403impl<V: Default> SeqMap<V> {
404 fn get_or_insert_default(&mut self, key: &str) -> &mut V {
405 if let Some(&i) = self.index.get(key) {
406 return &mut self.entries[i].1;
407 }
408 self.index.insert(key.to_string(), self.entries.len());
409 self.entries.push((key.to_string(), V::default()));
410 let last = self.entries.len() - 1;
411 &mut self.entries[last].1
412 }
413}
414
415fn push_ordered_unique(map: &mut HashMap<String, Vec<usize>>, key: &str, value: usize) {
416 let v = map.entry(key.to_string()).or_default();
417 if !v.contains(&value) {
418 v.push(value);
419 }
420}
421
422#[derive(Debug, Clone, Default)]
427pub struct InboundEntry {
429 pub inherits: Vec<usize>,
431 pub uses_type: Vec<usize>,
433 pub uses_member: Vec<usize>,
435}
436
437pub type HeuristicEntry = InboundEntry;
442
443#[derive(Debug, Clone, Default)]
444pub struct OutboundEntry {
446 pub inherits: Vec<usize>,
448 pub uses_type: Vec<usize>,
450 pub uses_member: Vec<usize>,
452 pub imports: Vec<usize>,
454}
455
456pub struct GraphIndex<'g> {
460 pub graph: &'g graph::Graph,
462 pub root: std::path::PathBuf,
466 pub by_id: HashMap<String, usize>,
468 pub by_file: HashMap<String, Vec<usize>>,
474 pub by_simple_name: HashMap<String, Vec<usize>>,
476 pub by_lower_name: HashMap<String, Vec<usize>>,
478 pub inbound: HashMap<String, InboundEntry>,
480 pub outbound_by_file: HashMap<String, OutboundEntry>,
482 pub ambiguous_by_candidate: HashMap<String, Vec<usize>>,
484 pub ambiguous_by_file: HashMap<String, Vec<usize>>,
486 pub heuristic_inbound: HashMap<String, HeuristicEntry>,
493 pub heuristic_outbound_by_file: HashMap<String, HeuristicEntry>,
495 pub test_defs_by_file: HashMap<String, Vec<usize>>,
502 pub flagged_files: HashSet<String>,
504 pub manifest_present: bool,
506 pub ctor_di_by_to: HashMap<String, Vec<usize>>,
511 pub ctor_di_fanin: HashMap<String, usize>,
519 pub hub_indegree: HashMap<String, usize>,
527}
528
529impl<'g> GraphIndex<'g> {
530 pub fn def(&self, id: &str) -> Option<&graph::Def> {
534 self.by_id.get(id).map(|&i| &self.graph.defs[i])
535 }
536}
537
538fn note_file(flagged: &mut HashSet<String>, manifest_paths: Option<&HashSet<String>>, file: &str) {
539 if let Some(paths) = manifest_paths {
540 if !file.is_empty() && !paths.contains(file) {
541 flagged.insert(file.to_string());
542 }
543 }
544}
545
546pub fn load_graph_index<'g>(graph: &'g graph::Graph, root: &Path) -> GraphIndex<'g> {
550 let manifest_value = match manifest::read_manifest(root) {
551 Ok(v) => v,
552 Err(_) => None, };
554 let manifest_present = manifest_value.is_some();
555 let manifest_paths: Option<HashSet<String>> = manifest_value.as_ref().and_then(|m| {
556 m.get("entries")
557 .and_then(|e| e.as_object())
558 .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
559 });
560 let manifest_paths_ref = manifest_paths.as_ref();
561
562 let mut flagged_files: HashSet<String> = HashSet::new();
563 let mut by_id: HashMap<String, usize> = HashMap::with_capacity(graph.defs.len());
564 let mut by_file: HashMap<String, Vec<usize>> = HashMap::new();
565 let mut by_simple_name: HashMap<String, Vec<usize>> = HashMap::new();
566 let mut by_lower_name: HashMap<String, Vec<usize>> = HashMap::new();
567 let mut test_defs_by_file: HashMap<String, Vec<usize>> = HashMap::new();
568
569 for (i, d) in graph.defs.iter().enumerate() {
570 by_id.insert(d.id.clone(), i);
571 note_file(&mut flagged_files, manifest_paths_ref, &d.file);
572 push_ordered_unique(&mut by_file, &d.file, i);
573 for also in &d.also_in {
574 note_file(&mut flagged_files, manifest_paths_ref, &also.file);
575 push_ordered_unique(&mut by_file, &also.file, i);
576 }
577 if !d.test_methods.is_empty() {
578 let mut files: Vec<&str> = vec![d.file.as_str()];
579 for also in &d.also_in {
580 if !files.contains(&also.file.as_str()) {
581 files.push(also.file.as_str());
582 }
583 }
584 for file in files {
585 test_defs_by_file
586 .entry(file.to_string())
587 .or_default()
588 .push(i);
589 }
590 }
591 by_simple_name.entry(d.name.clone()).or_default().push(i);
592 by_lower_name
593 .entry(d.name.to_lowercase())
594 .or_default()
595 .push(i);
596 }
597
598 let mut inbound: HashMap<String, InboundEntry> = HashMap::new();
599 let mut outbound_by_file: HashMap<String, OutboundEntry> = HashMap::new();
600 let mut ambiguous_by_candidate: HashMap<String, Vec<usize>> = HashMap::new();
601 let mut ambiguous_by_file: HashMap<String, Vec<usize>> = HashMap::new();
602 let mut heuristic_inbound: HashMap<String, HeuristicEntry> = HashMap::new();
603 let mut heuristic_outbound_by_file: HashMap<String, HeuristicEntry> = HashMap::new();
604 let mut ctor_di_by_to: HashMap<String, Vec<usize>> = HashMap::new();
605 let mut ctor_di_sites_by_iface: HashMap<String, HashSet<(String, usize)>> = HashMap::new();
606 let mut hub_referrers_by_file: HashMap<String, HashSet<String>> = HashMap::new();
608
609 for (i, e) in graph.edges.iter().enumerate() {
610 match e {
611 graph::Edge::Imports { from_file, .. } => {
612 note_file(&mut flagged_files, manifest_paths_ref, from_file);
613 outbound_by_file
614 .entry(from_file.clone())
615 .or_default()
616 .imports
617 .push(i);
618 }
619 graph::Edge::Ambiguous {
620 from_file,
621 candidates,
622 ..
623 } => {
624 note_file(&mut flagged_files, manifest_paths_ref, from_file);
625 ambiguous_by_file
626 .entry(from_file.clone())
627 .or_default()
628 .push(i);
629 for c in candidates {
630 ambiguous_by_candidate
631 .entry(c.id.clone())
632 .or_default()
633 .push(i);
634 }
635 }
636 graph::Edge::Inherits {
637 from_file,
638 to,
639 to_file,
640 heuristic,
641 ..
642 } => {
643 note_file(&mut flagged_files, manifest_paths_ref, from_file);
644 note_file(&mut flagged_files, manifest_paths_ref, to_file);
645 if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
649 hub_referrers_by_file
650 .entry(to_file.clone())
651 .or_default()
652 .insert(from_file.clone());
653 }
654 if *heuristic {
655 heuristic_outbound_by_file
656 .entry(from_file.clone())
657 .or_default()
658 .inherits
659 .push(i);
660 heuristic_inbound
661 .entry(to.clone())
662 .or_default()
663 .inherits
664 .push(i);
665 } else {
666 outbound_by_file
667 .entry(from_file.clone())
668 .or_default()
669 .inherits
670 .push(i);
671 inbound.entry(to.clone()).or_default().inherits.push(i);
672 }
673 }
674 graph::Edge::UsesType {
675 from_file,
676 to,
677 to_file,
678 heuristic,
679 ..
680 } => {
681 note_file(&mut flagged_files, manifest_paths_ref, from_file);
682 note_file(&mut flagged_files, manifest_paths_ref, to_file);
683 if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
687 hub_referrers_by_file
688 .entry(to_file.clone())
689 .or_default()
690 .insert(from_file.clone());
691 }
692 if *heuristic {
693 heuristic_outbound_by_file
694 .entry(from_file.clone())
695 .or_default()
696 .uses_type
697 .push(i);
698 heuristic_inbound
699 .entry(to.clone())
700 .or_default()
701 .uses_type
702 .push(i);
703 } else {
704 outbound_by_file
705 .entry(from_file.clone())
706 .or_default()
707 .uses_type
708 .push(i);
709 inbound.entry(to.clone()).or_default().uses_type.push(i);
710 }
711 }
712 graph::Edge::UsesMember {
713 from_file,
714 to,
715 to_file,
716 heuristic,
717 ..
718 } => {
719 note_file(&mut flagged_files, manifest_paths_ref, from_file);
720 note_file(&mut flagged_files, manifest_paths_ref, to_file);
721 if !to_file.is_empty() && !from_file.is_empty() && to_file != from_file {
725 hub_referrers_by_file
726 .entry(to_file.clone())
727 .or_default()
728 .insert(from_file.clone());
729 }
730 if *heuristic {
731 heuristic_outbound_by_file
732 .entry(from_file.clone())
733 .or_default()
734 .uses_member
735 .push(i);
736 heuristic_inbound
737 .entry(to.clone())
738 .or_default()
739 .uses_member
740 .push(i);
741 } else {
742 outbound_by_file
743 .entry(from_file.clone())
744 .or_default()
745 .uses_member
746 .push(i);
747 inbound.entry(to.clone()).or_default().uses_member.push(i);
748 }
749 }
750 graph::Edge::CtorDi {
756 from_file,
757 from_line,
758 iface,
759 to,
760 ..
761 } => {
762 ctor_di_sites_by_iface
766 .entry(iface.clone())
767 .or_default()
768 .insert((from_file.clone(), *from_line));
769 if let Some(to) = to {
770 ctor_di_by_to.entry(to.clone()).or_default().push(i);
771 }
772 }
773 graph::Edge::Import { .. }
779 | graph::Edge::Call { .. }
780 | graph::Edge::JsxUse { .. }
781 | graph::Edge::Dispatch { .. } => {}
782 }
783 }
784
785 GraphIndex {
786 graph,
787 root: root.to_path_buf(),
788 by_id,
789 by_file,
790 by_simple_name,
791 by_lower_name,
792 inbound,
793 outbound_by_file,
794 ambiguous_by_candidate,
795 ambiguous_by_file,
796 heuristic_inbound,
797 heuristic_outbound_by_file,
798 test_defs_by_file,
799 flagged_files,
800 manifest_present,
801 ctor_di_by_to,
802 ctor_di_fanin: ctor_di_sites_by_iface
803 .into_iter()
804 .map(|(name, sites)| (name, sites.len()))
805 .collect(),
806 hub_indegree: hub_referrers_by_file
807 .into_iter()
808 .map(|(file, refs)| (file, refs.len()))
809 .collect(),
810 }
811}
812
813fn implemented_interfaces(index: &GraphIndex, def_id: &str) -> Vec<String> {
821 let mut seen: SeqSet<String> = SeqSet::new();
822 for file in def_files(index, def_id) {
823 let Some(o) = index.outbound_by_file.get(&file) else {
824 continue;
825 };
826 for &ei in &o.inherits {
827 let graph::Edge::Inherits { to, .. } = &index.graph.edges[ei] else {
828 continue;
829 };
830 if seen.contains(to) {
831 continue;
832 }
833 if index.def(to).map(|d| d.kind.as_str()) != Some("interface") {
834 continue;
835 }
836 seen.insert(to.clone());
837 }
838 }
839 seen.into_vec()
840}
841
842#[derive(Debug, Clone, PartialEq, Eq)]
847pub enum Resolution {
849 Resolved(String),
851 Ambiguous(Vec<String>),
853 NotFound,
855}
856
857pub fn resolve_symbol(index: &GraphIndex, query: &str) -> Resolution {
861 if index.by_id.contains_key(query) {
862 return Resolution::Resolved(query.to_string());
863 }
864 if let Some(exact) = index.by_simple_name.get(query) {
865 if exact.len() == 1 {
866 return Resolution::Resolved(index.graph.defs[exact[0]].id.clone());
867 }
868 if exact.len() > 1 {
869 return Resolution::Ambiguous(
870 exact
871 .iter()
872 .map(|&i| index.graph.defs[i].id.clone())
873 .collect(),
874 );
875 }
876 }
877 let lower = query.to_lowercase();
878 if let Some(ci) = index.by_lower_name.get(&lower) {
879 if ci.len() == 1 {
880 return Resolution::Resolved(index.graph.defs[ci[0]].id.clone());
881 }
882 if ci.len() > 1 {
883 return Resolution::Ambiguous(
884 ci.iter().map(|&i| index.graph.defs[i].id.clone()).collect(),
885 );
886 }
887 }
888 if query.contains('.') {
899 let suffix = format!(".{query}");
900 let tail: Vec<String> = index
901 .graph
902 .defs
903 .iter()
904 .filter(|d| d.id.ends_with(&suffix))
905 .map(|d| d.id.clone())
906 .collect();
907 if tail.len() == 1 {
908 return Resolution::Resolved(tail.into_iter().next().expect("one tail match"));
909 }
910 if tail.len() > 1 {
911 return Resolution::Ambiguous(tail);
912 }
913 }
914 Resolution::NotFound
915}
916
917#[derive(Debug, Clone, PartialEq)]
922pub struct DefSite {
924 pub file: String,
926 pub line: usize,
928}
929
930pub fn def_files(index: &GraphIndex, def_id: &str) -> Vec<String> {
933 let mut out = Vec::new();
934 let mut seen = HashSet::new();
935 if let Some(&i) = index.by_id.get(def_id) {
936 let d = &index.graph.defs[i];
937 if seen.insert(d.file.clone()) {
938 out.push(d.file.clone());
939 }
940 for also in &d.also_in {
941 if seen.insert(also.file.clone()) {
942 out.push(also.file.clone());
943 }
944 }
945 }
946 out
947}
948
949pub fn def_sites(index: &GraphIndex, def_id: &str) -> Vec<DefSite> {
952 let mut out = Vec::new();
953 if let Some(&i) = index.by_id.get(def_id) {
954 let d = &index.graph.defs[i];
955 out.push(DefSite {
956 file: d.file.clone(),
957 line: d.line,
958 });
959 for also in &d.also_in {
960 out.push(DefSite {
961 file: also.file.clone(),
962 line: also.line,
963 });
964 }
965 }
966 out
967}
968
969#[derive(Debug, Clone, Default)]
977pub struct SymbolRefs {
978 pub inbound_inherits: Vec<usize>,
980 pub inbound_uses_type: Vec<usize>,
982 pub inbound_uses_member: Vec<usize>,
984 pub outbound_inherits: Vec<usize>,
986 pub outbound_uses_type: Vec<usize>,
988 pub outbound_uses_member: Vec<usize>,
990 pub outbound_imports: Vec<usize>,
992 pub heuristic_inbound_inherits: Vec<usize>,
997 pub heuristic_inbound_uses_type: Vec<usize>,
999 pub heuristic_inbound_uses_member: Vec<usize>,
1001 pub heuristic_outbound_inherits: Vec<usize>,
1003 pub heuristic_outbound_uses_type: Vec<usize>,
1005 pub heuristic_outbound_uses_member: Vec<usize>,
1007 pub ambiguous_inbound: Vec<usize>,
1009 pub ambiguous_outbound: Vec<usize>,
1011}
1012
1013pub fn symbol_refs(index: &GraphIndex, def_id: &str) -> SymbolRefs {
1018 let empty = InboundEntry::default();
1019 let base = index.inbound.get(def_id).unwrap_or(&empty);
1020 let inbound_inherits = base.inherits.clone();
1021 let inbound_uses_type = base.uses_type.clone();
1022 let mut inbound_uses_member = base.uses_member.clone();
1023
1024 if let Some(&i) = index.by_id.get(def_id) {
1025 if index.graph.defs[i].kind == "enum" {
1026 let prefix = format!("{def_id}.");
1027 for d in &index.graph.defs {
1028 if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
1029 continue;
1030 }
1031 if let Some(m) = index.inbound.get(&d.id) {
1032 inbound_uses_member.extend(m.uses_member.iter().copied());
1033 }
1034 }
1035 }
1036 }
1037
1038 let mut outbound_inherits = Vec::new();
1039 let mut outbound_uses_type = Vec::new();
1040 let mut outbound_uses_member = Vec::new();
1041 let mut outbound_imports = Vec::new();
1042 for file in def_files(index, def_id) {
1043 if let Some(o) = index.outbound_by_file.get(&file) {
1044 outbound_inherits.extend(o.inherits.iter().copied());
1045 outbound_uses_type.extend(o.uses_type.iter().copied());
1046 outbound_uses_member.extend(o.uses_member.iter().copied());
1047 outbound_imports.extend(o.imports.iter().copied());
1048 }
1049 }
1050
1051 let heuristic_base = index.heuristic_inbound.get(def_id).unwrap_or(&empty);
1053 let heuristic_inbound_inherits = heuristic_base.inherits.clone();
1054 let heuristic_inbound_uses_type = heuristic_base.uses_type.clone();
1055 let mut heuristic_inbound_uses_member = heuristic_base.uses_member.clone();
1056
1057 if let Some(&i) = index.by_id.get(def_id) {
1058 if index.graph.defs[i].kind == "enum" {
1059 let prefix = format!("{def_id}.");
1060 for d in &index.graph.defs {
1061 if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
1062 continue;
1063 }
1064 if let Some(m) = index.heuristic_inbound.get(&d.id) {
1065 heuristic_inbound_uses_member.extend(m.uses_member.iter().copied());
1066 }
1067 }
1068 }
1069 }
1070
1071 let mut heuristic_outbound_inherits = Vec::new();
1072 let mut heuristic_outbound_uses_type = Vec::new();
1073 let mut heuristic_outbound_uses_member = Vec::new();
1074 for file in def_files(index, def_id) {
1075 if let Some(o) = index.heuristic_outbound_by_file.get(&file) {
1076 heuristic_outbound_inherits.extend(o.inherits.iter().copied());
1077 heuristic_outbound_uses_type.extend(o.uses_type.iter().copied());
1078 heuristic_outbound_uses_member.extend(o.uses_member.iter().copied());
1079 }
1080 }
1081
1082 let ambiguous_inbound = index
1083 .ambiguous_by_candidate
1084 .get(def_id)
1085 .cloned()
1086 .unwrap_or_default();
1087 let ambiguous_outbound: Vec<usize> = def_files(index, def_id)
1088 .iter()
1089 .flat_map(|f| index.ambiguous_by_file.get(f).cloned().unwrap_or_default())
1090 .collect();
1091
1092 SymbolRefs {
1093 inbound_inherits,
1094 inbound_uses_type,
1095 inbound_uses_member,
1096 outbound_inherits,
1097 outbound_uses_type,
1098 outbound_uses_member,
1099 outbound_imports,
1100 heuristic_inbound_inherits,
1101 heuristic_inbound_uses_type,
1102 heuristic_inbound_uses_member,
1103 heuristic_outbound_inherits,
1104 heuristic_outbound_uses_type,
1105 heuristic_outbound_uses_member,
1106 ambiguous_inbound,
1107 ambiguous_outbound,
1108 }
1109}
1110
1111fn cap_rows<T>(mut rows: Vec<T>, cap: usize) -> (Vec<T>, usize) {
1116 if rows.len() <= cap {
1117 (rows, 0)
1118 } else {
1119 let dropped = rows.len() - cap;
1120 rows.truncate(cap);
1121 (rows, dropped)
1122 }
1123}
1124
1125fn edge_loc(e: &graph::Edge) -> (&str, usize) {
1128 match e {
1129 graph::Edge::Inherits { from_file, from_line, .. }
1130 | graph::Edge::UsesType { from_file, from_line, .. }
1131 | graph::Edge::UsesMember { from_file, from_line, .. }
1132 | graph::Edge::Imports { from_file, from_line, .. }
1133 | graph::Edge::Ambiguous { from_file, from_line, .. }
1134 | graph::Edge::CtorDi { from_file, from_line, .. }
1139 | graph::Edge::Import { from_file, from_line, .. }
1140 | graph::Edge::Call { from_file, from_line, .. }
1141 | graph::Edge::JsxUse { from_file, from_line, .. }
1142 | graph::Edge::Dispatch { from_file, from_line, .. } => (from_file.as_str(), *from_line),
1143 }
1144}
1145
1146fn loc_cmp(a: &graph::Edge, b: &graph::Edge) -> std::cmp::Ordering {
1147 let (af, al) = edge_loc(a);
1148 let (bf, bl) = edge_loc(b);
1149 if af == bf {
1150 al.cmp(&bl)
1151 } else {
1152 af.cmp(bf)
1153 }
1154}
1155
1156#[derive(Debug, Clone, PartialEq)]
1160pub struct InboundRow {
1161 pub file: String,
1163 pub line: usize,
1165 pub heuristic: bool,
1167 pub source: String,
1169}
1170
1171#[derive(Debug, Clone, PartialEq)]
1177pub struct OutboundRow {
1178 pub file: String,
1180 pub line: usize,
1182 pub to_file: String,
1184 pub to: String,
1186 pub heuristic: bool,
1188 pub source: String,
1190}
1191
1192#[derive(Debug, Clone, PartialEq)]
1196pub struct ImportRow {
1197 pub file: String,
1199 pub line: usize,
1201 pub target: String,
1203 pub source: String,
1205}
1206
1207#[derive(Debug, Clone, PartialEq)]
1210pub struct AmbiguousRow {
1211 pub file: String,
1213 pub line: usize,
1215 pub origin: String,
1217 pub raw: String,
1219 pub candidate_count: usize,
1221}
1222
1223#[derive(Debug, Clone, PartialEq)]
1226pub struct Table<R> {
1227 pub total: usize,
1229 pub dropped: usize,
1231 pub rows: Vec<R>,
1233}
1234
1235fn build_table<R>(
1236 mut idxs: Vec<usize>,
1237 edges: &[graph::Edge],
1238 cap: usize,
1239 map_row: fn(&graph::Edge) -> R,
1240) -> Table<R> {
1241 idxs.sort_by(|&a, &b| loc_cmp(&edges[a], &edges[b]));
1242 let total = idxs.len();
1243 let (shown, dropped) = cap_rows(idxs, cap);
1244 let rows = shown.into_iter().map(|i| map_row(&edges[i])).collect();
1245 Table {
1246 total,
1247 dropped,
1248 rows,
1249 }
1250}
1251
1252fn ambiguous_row(e: &graph::Edge) -> AmbiguousRow {
1259 match e {
1260 graph::Edge::Ambiguous {
1261 origin,
1262 from_file,
1263 from_line,
1264 raw,
1265 candidate_count,
1266 ..
1267 } => AmbiguousRow {
1268 file: from_file.clone(),
1269 line: *from_line,
1270 origin: origin.clone(),
1271 raw: raw.clone(),
1272 candidate_count: *candidate_count,
1273 },
1274 _ => unreachable!("ambiguous table only ever holds ambiguous edge indices"),
1275 }
1276}
1277
1278#[derive(Debug, Clone, PartialEq)]
1280pub struct InboundTables {
1281 pub inherits: Table<InboundRow>,
1283 pub uses_type: Table<InboundRow>,
1285 pub uses_member: Table<InboundRow>,
1287}
1288
1289#[derive(Debug, Clone, PartialEq)]
1291pub struct OutboundTables {
1292 pub inherits: Table<OutboundRow>,
1294 pub uses_type: Table<OutboundRow>,
1296 pub uses_member: Table<OutboundRow>,
1298 pub imports: Table<ImportRow>,
1300}
1301
1302#[derive(Debug, Clone, PartialEq)]
1304pub struct AmbiguousTables {
1305 pub inbound: Table<AmbiguousRow>,
1307 pub outbound: Table<AmbiguousRow>,
1309}
1310
1311#[derive(Debug, Clone, PartialEq)]
1313pub struct MemberRefEntry {
1314 pub name: String,
1316 pub count: usize,
1318}
1319
1320#[derive(Debug, Clone, PartialEq)]
1323pub struct MemberRefs {
1324 pub total: usize,
1326 pub member_count: usize,
1328 pub members: Vec<MemberRefEntry>,
1330 pub dropped: usize,
1332}
1333
1334const MEMBER_NAME_CAP: usize = 5;
1336
1337fn enum_member_refs(index: &GraphIndex, def_id: &str) -> Option<MemberRefs> {
1348 let prefix = format!("{def_id}.");
1349 let mut members: Vec<MemberRefEntry> = Vec::new();
1350 let mut total = 0usize;
1351 for d in &index.graph.defs {
1352 if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
1353 continue;
1354 }
1355 let count = index.inbound.get(&d.id).map_or(0, |e| e.uses_member.len())
1356 + index
1357 .heuristic_inbound
1358 .get(&d.id)
1359 .map_or(0, |e| e.uses_member.len());
1360 if count == 0 {
1361 continue;
1362 }
1363 members.push(MemberRefEntry {
1364 name: d.name.clone(),
1365 count,
1366 });
1367 total += count;
1368 }
1369 if total == 0 {
1370 return None;
1371 }
1372 let member_count = members.len();
1373 let dropped = member_count.saturating_sub(MEMBER_NAME_CAP);
1374 members.truncate(MEMBER_NAME_CAP);
1375 Some(MemberRefs {
1376 total,
1377 member_count,
1378 members,
1379 dropped,
1380 })
1381}
1382
1383#[derive(Debug, Clone, PartialEq)]
1385pub struct RefsModel {
1386 pub query: String,
1388 pub id: String,
1390 pub kind: String,
1392 pub sites: Vec<DefSite>,
1394 pub inbound: InboundTables,
1396 pub outbound: Option<OutboundTables>,
1400 pub ambiguous: AmbiguousTables,
1402 pub manifest_gap: usize,
1404 pub member_refs: Option<MemberRefs>,
1407}
1408
1409#[derive(Debug, Clone, PartialEq)]
1412pub enum RefsResult {
1413 Resolved(RefsModel),
1415 Ambiguous(Vec<String>),
1417 Members(Vec<RefsModel>),
1419 NotFound,
1421}
1422
1423fn project_of(file: &str) -> &str {
1428 match file.find('/') {
1429 Some(i) => &file[..i],
1430 None => "",
1431 }
1432}
1433
1434fn trim_source(text: &str) -> &str {
1439 text.trim_matches(|c| matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0b}' | '\u{0c}'))
1440}
1441
1442pub type LineCache = HashMap<String, Option<Vec<String>>>;
1446
1447fn cached_line(root: &Path, file: &str, line: usize, cache: &mut LineCache) -> String {
1448 let lines = cache.entry(file.to_string()).or_insert_with(|| {
1449 std::fs::read_to_string(root.join(file))
1450 .ok()
1451 .map(|body| body.split('\n').map(str::to_string).collect())
1452 });
1453 let Some(lines) = lines else {
1454 return String::new();
1455 };
1456 if line < 1 || line > lines.len() {
1457 return String::new();
1458 }
1459 let mut raw = lines[line - 1].as_str();
1460 if line == 1 {
1463 raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
1464 }
1465 trim_source(raw).to_string()
1466}
1467
1468fn clip_source(text: &str) -> String {
1470 let text = text.replace('\t', " ");
1471 let units: Vec<u16> = text.encode_utf16().collect();
1475 if units.len() > SOURCE_MAX {
1476 String::from_utf16_lossy(&units[..SOURCE_MAX])
1477 } else {
1478 text
1479 }
1480}
1481
1482fn hit_source(root: &Path, file: &str, line: usize, cache: &mut LineCache) -> String {
1485 clip_source(&cached_line(root, file, line, cache))
1486}
1487
1488fn is_word_byte(b: u8) -> bool {
1502 b.is_ascii_alphanumeric() || b == b'_'
1503}
1504
1505fn line_has_token(line: &str, token: &str) -> bool {
1506 let hay = line.as_bytes();
1507 let needle = token.as_bytes();
1508 if needle.is_empty() || needle.len() > hay.len() {
1509 return false;
1510 }
1511 for at in 0..=(hay.len() - needle.len()) {
1512 if &hay[at..at + needle.len()] != needle {
1513 continue;
1514 }
1515 let after_at = at + needle.len();
1516 let before_ok = at == 0 || !is_word_byte(hay[at - 1]);
1517 let after_ok = after_at >= hay.len() || !is_word_byte(hay[after_at]);
1518 if before_ok && after_ok {
1519 return true;
1520 }
1521 }
1522 false
1523}
1524
1525fn member_owners(index: &GraphIndex, name: &str) -> Vec<(String, Vec<DefSite>)> {
1530 let mut out: Vec<(String, Vec<DefSite>)> = Vec::new();
1531 let mut at: HashMap<&str, usize> = HashMap::new();
1532 for n in &index.graph.names {
1533 if n.name != name || n.owner.is_empty() || !index.by_id.contains_key(&n.owner) {
1534 continue;
1535 }
1536 let site = DefSite {
1537 file: n.file.clone(),
1538 line: n.line,
1539 };
1540 match at.get(n.owner.as_str()) {
1541 Some(&i) => out[i].1.push(site),
1542 None => {
1543 at.insert(n.owner.as_str(), out.len());
1544 out.push((n.owner.clone(), vec![site]));
1545 }
1546 }
1547 }
1548 out
1549}
1550
1551enum MemberRefsOutcome {
1555 Models(Vec<RefsModel>),
1556 Ambiguous(Vec<String>),
1557}
1558
1559fn build_member_refs_models(
1571 index: &GraphIndex,
1572 name: &str,
1573 inbound_cap: usize,
1574) -> Option<MemberRefsOutcome> {
1575 let owners = member_owners(index, name);
1576 if owners.is_empty() {
1577 return None;
1578 }
1579 let edges = &index.graph.edges;
1580 let mut cache: LineCache = HashMap::new();
1581 let mut groups: Vec<(String, Vec<DefSite>, Vec<(usize, bool)>)> = Vec::new();
1582 for (owner, sites) in owners {
1583 let refs = symbol_refs(index, &owner);
1584 let owner_def = &index.graph.defs[index.by_id[&owner]];
1585 let owner_project = project_of(&owner_def.file).to_string();
1586 let mut kept: Vec<(usize, bool)> = Vec::new();
1587 for &e in &refs.inbound_uses_member {
1588 kept.push((e, false));
1589 }
1590 for &e in &refs.heuristic_inbound_uses_member {
1591 kept.push((e, true));
1592 }
1593 kept.retain(|&(e, _)| {
1594 let (file, line) = edge_loc(&edges[e]);
1595 line_has_token(&cached_line(&index.root, file, line, &mut cache), name)
1596 });
1597 let foreign = |e: usize| usize::from(project_of(edge_loc(&edges[e]).0) != owner_project);
1601 kept.sort_by(|a, b| {
1602 a.1.cmp(&b.1)
1603 .then_with(|| foreign(a.0).cmp(&foreign(b.0)))
1604 .then_with(|| loc_cmp(&edges[a.0], &edges[b.0]))
1605 });
1606 if !kept.is_empty() {
1607 groups.push((owner, sites, kept));
1608 }
1609 }
1610 if groups.is_empty() {
1611 return None;
1612 }
1613 if groups.len() > 1 {
1617 return Some(MemberRefsOutcome::Ambiguous(
1618 groups.into_iter().map(|(owner, _, _)| owner).collect(),
1619 ));
1620 }
1621
1622 fn empty<R>() -> Table<R> {
1623 Table {
1624 total: 0,
1625 dropped: 0,
1626 rows: Vec::new(),
1627 }
1628 }
1629 let mut budget = inbound_cap;
1630 let mut models = Vec::new();
1631 for (owner, sites, kept) in groups {
1632 let take = budget.min(kept.len());
1633 budget -= take;
1634 let mut rows = Vec::new();
1635 for &(e, heuristic) in &kept[..take] {
1636 let (file, line) = edge_loc(&edges[e]);
1637 let source = clip_source(&cached_line(&index.root, file, line, &mut cache));
1638 rows.push(InboundRow {
1639 file: file.to_string(),
1640 line,
1641 heuristic,
1642 source,
1643 });
1644 }
1645 let total = kept.len();
1646 models.push(RefsModel {
1647 query: name.to_string(),
1648 id: format!("{owner}.{name}"),
1649 kind: "member".to_string(),
1650 sites,
1651 inbound: InboundTables {
1652 inherits: empty(),
1653 uses_type: empty(),
1654 uses_member: Table {
1655 total,
1656 dropped: total - rows.len(),
1657 rows,
1658 },
1659 },
1660 outbound: None,
1661 ambiguous: AmbiguousTables {
1662 inbound: empty(),
1663 outbound: empty(),
1664 },
1665 manifest_gap: index.flagged_files.len(),
1666 member_refs: None,
1667 });
1668 }
1669 Some(MemberRefsOutcome::Models(models))
1670}
1671
1672struct RankedInbound {
1675 kind: usize,
1676 edge: usize,
1677 heuristic: bool,
1678}
1679
1680struct RankedOutbound {
1686 kind: usize,
1687 edge: usize,
1688 heuristic: bool,
1689}
1690
1691fn outbound_foreign(def_project: &str, edges: &[graph::Edge], r: &RankedOutbound) -> usize {
1697 if r.kind == 3 {
1698 return 1;
1699 }
1700 let to_file = match &edges[r.edge] {
1701 graph::Edge::Inherits { to_file, .. }
1702 | graph::Edge::UsesType { to_file, .. }
1703 | graph::Edge::UsesMember { to_file, .. } => to_file.as_str(),
1704 _ => unreachable!(
1705 "outbound ranked kinds 0-2 only ever hold inherits/uses-type/uses-member edge indices"
1706 ),
1707 };
1708 usize::from(project_of(to_file) != def_project)
1709}
1710
1711fn build_outbound_tables(
1719 refs: &SymbolRefs,
1720 def_project: &str,
1721 edges: &[graph::Edge],
1722 root: &Path,
1723 outbound_cap: usize,
1724) -> OutboundTables {
1725 let mut ranked: Vec<RankedOutbound> = Vec::new();
1726 for &e in &refs.outbound_inherits {
1727 ranked.push(RankedOutbound {
1728 kind: 0,
1729 edge: e,
1730 heuristic: false,
1731 });
1732 }
1733 for &e in &refs.heuristic_outbound_inherits {
1734 ranked.push(RankedOutbound {
1735 kind: 0,
1736 edge: e,
1737 heuristic: true,
1738 });
1739 }
1740 for &e in &refs.outbound_uses_type {
1741 ranked.push(RankedOutbound {
1742 kind: 1,
1743 edge: e,
1744 heuristic: false,
1745 });
1746 }
1747 for &e in &refs.heuristic_outbound_uses_type {
1748 ranked.push(RankedOutbound {
1749 kind: 1,
1750 edge: e,
1751 heuristic: true,
1752 });
1753 }
1754 for &e in &refs.outbound_uses_member {
1755 ranked.push(RankedOutbound {
1756 kind: 2,
1757 edge: e,
1758 heuristic: false,
1759 });
1760 }
1761 for &e in &refs.heuristic_outbound_uses_member {
1762 ranked.push(RankedOutbound {
1763 kind: 2,
1764 edge: e,
1765 heuristic: true,
1766 });
1767 }
1768 for &e in &refs.outbound_imports {
1769 ranked.push(RankedOutbound {
1770 kind: 3,
1771 edge: e,
1772 heuristic: false,
1773 });
1774 }
1775 let mut totals = [0usize; 4];
1776 for r in &ranked {
1777 totals[r.kind] += 1;
1778 }
1779 ranked.sort_by(|a, b| {
1780 a.heuristic
1781 .cmp(&b.heuristic)
1782 .then_with(|| {
1783 outbound_foreign(def_project, edges, a).cmp(&outbound_foreign(
1784 def_project,
1785 edges,
1786 b,
1787 ))
1788 })
1789 .then_with(|| loc_cmp(&edges[a.edge], &edges[b.edge]))
1790 });
1791 let (shown, _) = cap_rows(ranked, outbound_cap);
1792
1793 let mut source_cache: LineCache = HashMap::new();
1794 let mut inherits = Vec::new();
1795 let mut uses_type = Vec::new();
1796 let mut uses_member = Vec::new();
1797 let mut imports = Vec::new();
1798 for r in shown {
1799 let (file, line) = edge_loc(&edges[r.edge]);
1800 let source = hit_source(root, file, line, &mut source_cache);
1801 if r.kind == 3 {
1802 let graph::Edge::Imports { target, .. } = &edges[r.edge] else {
1803 unreachable!("outbound kind 3 only ever holds imports edge indices");
1804 };
1805 imports.push(ImportRow {
1806 file: file.to_string(),
1807 line,
1808 target: target.clone(),
1809 source,
1810 });
1811 continue;
1812 }
1813 let (to_file, to) = match &edges[r.edge] {
1814 graph::Edge::Inherits { to_file, to, .. }
1815 | graph::Edge::UsesType { to_file, to, .. }
1816 | graph::Edge::UsesMember { to_file, to, .. } => (to_file.clone(), to.clone()),
1817 _ => unreachable!("outbound ranked kinds 0-2 only ever hold inherits/uses-type/uses-member edge indices"),
1818 };
1819 let row = OutboundRow {
1820 file: file.to_string(),
1821 line,
1822 to_file,
1823 to,
1824 heuristic: r.heuristic,
1825 source,
1826 };
1827 match r.kind {
1828 0 => inherits.push(row),
1829 1 => uses_type.push(row),
1830 _ => uses_member.push(row),
1831 }
1832 }
1833
1834 OutboundTables {
1835 inherits: Table {
1836 total: totals[0],
1837 dropped: totals[0] - inherits.len(),
1838 rows: inherits,
1839 },
1840 uses_type: Table {
1841 total: totals[1],
1842 dropped: totals[1] - uses_type.len(),
1843 rows: uses_type,
1844 },
1845 uses_member: Table {
1846 total: totals[2],
1847 dropped: totals[2] - uses_member.len(),
1848 rows: uses_member,
1849 },
1850 imports: Table {
1851 total: totals[3],
1852 dropped: totals[3] - imports.len(),
1853 rows: imports,
1854 },
1855 }
1856}
1857
1858pub fn build_refs_model(
1862 index: &GraphIndex,
1863 query: &str,
1864 out: bool,
1865 cap: usize,
1866 inbound_cap: usize,
1867 outbound_cap: usize,
1868 all_out: bool,
1869) -> RefsResult {
1870 build_refs_model_inner(
1871 index,
1872 query,
1873 out,
1874 cap,
1875 inbound_cap,
1876 outbound_cap,
1877 all_out,
1878 false,
1879 )
1880}
1881
1882#[allow(clippy::too_many_arguments)]
1883fn build_refs_model_inner(
1884 index: &GraphIndex,
1885 query: &str,
1886 out: bool,
1887 cap: usize,
1888 inbound_cap: usize,
1889 outbound_cap: usize,
1890 all_out: bool,
1891 exclude_self_inbound: bool,
1892) -> RefsResult {
1893 let id = match resolve_symbol(index, query) {
1894 Resolution::Resolved(id) => id,
1895 Resolution::Ambiguous(ids) => return RefsResult::Ambiguous(ids),
1896 Resolution::NotFound => {
1902 return match build_member_refs_models(index, query, inbound_cap) {
1903 Some(MemberRefsOutcome::Models(models)) => RefsResult::Members(models),
1904 Some(MemberRefsOutcome::Ambiguous(ids)) => RefsResult::Ambiguous(ids),
1905 None => RefsResult::NotFound,
1906 };
1907 }
1908 };
1909 let def_idx = *index
1910 .by_id
1911 .get(&id)
1912 .expect("resolved id must exist in the index it was resolved from");
1913 let def = &index.graph.defs[def_idx];
1914 let refs = symbol_refs(index, &id);
1915 let edges = &index.graph.edges;
1916
1917 let def_project = project_of(&def.file);
1925 let mut ranked: Vec<RankedInbound> = Vec::new();
1926 let mut totals = [0usize; 3];
1927 let is_self_inbound = |edge: usize| {
1928 let (file, line) = edge_loc(&edges[edge]);
1929 exclude_self_inbound
1930 && def.end_line >= def.line
1931 && file == def.file
1932 && (def.line..=def.end_line).contains(&line)
1933 };
1934 for (kind, (precise, heuristic)) in [
1935 (&refs.inbound_inherits, &refs.heuristic_inbound_inherits),
1936 (&refs.inbound_uses_type, &refs.heuristic_inbound_uses_type),
1937 (
1938 &refs.inbound_uses_member,
1939 &refs.heuristic_inbound_uses_member,
1940 ),
1941 ]
1942 .into_iter()
1943 .enumerate()
1944 {
1945 for &e in precise {
1946 if is_self_inbound(e) {
1947 continue;
1948 }
1949 ranked.push(RankedInbound {
1950 kind,
1951 edge: e,
1952 heuristic: false,
1953 });
1954 totals[kind] += 1;
1955 }
1956 for &e in heuristic {
1957 if is_self_inbound(e) {
1958 continue;
1959 }
1960 ranked.push(RankedInbound {
1961 kind,
1962 edge: e,
1963 heuristic: true,
1964 });
1965 totals[kind] += 1;
1966 }
1967 }
1968 let foreign = |e: usize| usize::from(project_of(edge_loc(&edges[e]).0) != def_project);
1969 ranked.sort_by(|a, b| {
1970 a.heuristic
1971 .cmp(&b.heuristic)
1972 .then_with(|| foreign(a.edge).cmp(&foreign(b.edge)))
1973 .then_with(|| loc_cmp(&edges[a.edge], &edges[b.edge]))
1974 });
1975 let (shown_inbound, _) = cap_rows(ranked, if all_out { usize::MAX } else { inbound_cap });
1979
1980 let mut source_cache: LineCache = HashMap::new();
1981 let mut rows: [Vec<InboundRow>; 3] = [Vec::new(), Vec::new(), Vec::new()];
1982 for r in shown_inbound {
1983 let (file, line) = edge_loc(&edges[r.edge]);
1984 let source = hit_source(&index.root, file, line, &mut source_cache);
1985 rows[r.kind].push(InboundRow {
1986 file: file.to_string(),
1987 line,
1988 heuristic: r.heuristic,
1989 source,
1990 });
1991 }
1992 let [inherits_rows, uses_type_rows, uses_member_rows] = rows;
1993 let inbound_table = |total: usize, rows: Vec<InboundRow>| Table {
1994 total,
1995 dropped: total - rows.len(),
1996 rows,
1997 };
1998 let inbound = InboundTables {
1999 inherits: inbound_table(totals[0], inherits_rows),
2000 uses_type: inbound_table(totals[1], uses_type_rows),
2001 uses_member: inbound_table(totals[2], uses_member_rows),
2002 };
2003
2004 let outbound = out.then(|| {
2005 build_outbound_tables(
2006 &refs,
2007 def_project,
2008 edges,
2009 &index.root,
2010 if all_out { usize::MAX } else { outbound_cap },
2011 )
2012 });
2013 let ambiguous = AmbiguousTables {
2014 inbound: build_table(refs.ambiguous_inbound, edges, cap, ambiguous_row),
2015 outbound: build_table(refs.ambiguous_outbound, edges, cap, ambiguous_row),
2016 };
2017
2018 let member_refs = if def.kind == "enum" {
2019 enum_member_refs(index, &id)
2020 } else {
2021 None
2022 };
2023
2024 RefsResult::Resolved(RefsModel {
2025 query: query.to_string(),
2026 id: id.clone(),
2027 kind: def.kind.clone(),
2028 sites: def_sites(index, &id),
2029 inbound,
2030 outbound,
2031 ambiguous,
2032 manifest_gap: index.flagged_files.len(),
2033 member_refs,
2034 })
2035}
2036
2037#[derive(Debug, Clone, PartialEq)]
2050pub struct ReadSpan {
2051 pub file: String,
2053 pub start_line: usize,
2055 pub end_line: usize,
2057 pub source: String,
2059}
2060
2061#[derive(Debug, Clone, PartialEq)]
2067pub struct ReadModel {
2068 pub refs: RefsModel,
2070 pub span: Option<ReadSpan>,
2072}
2073
2074#[derive(Debug, Clone, PartialEq)]
2077pub enum ReadResult {
2078 Resolved(Box<ReadModel>),
2080 Members(Vec<RefsModel>),
2082 Ambiguous(Vec<String>),
2084 NotFound,
2086}
2087
2088fn read_span(root: &Path, model: &RefsModel, end_line: usize) -> Option<ReadSpan> {
2092 let site = model.sites.first()?;
2093 if end_line < site.line {
2094 return None;
2095 }
2096 let body = std::fs::read_to_string(root.join(&site.file)).ok()?;
2097 let lines: Vec<&str> = body.split('\n').collect();
2098 if site.line < 1 || site.line > lines.len() {
2099 return None;
2100 }
2101 let end = end_line.min(lines.len());
2104 let mut start_text = lines[site.line - 1];
2105 if site.line == 1 {
2106 start_text = start_text.strip_prefix('\u{feff}').unwrap_or(start_text);
2107 }
2108 let mut source = String::from(start_text);
2109 for line in &lines[site.line..end] {
2110 source.push('\n');
2111 source.push_str(line);
2112 }
2113 Some(ReadSpan {
2114 file: site.file.clone(),
2115 start_line: site.line,
2116 end_line: end,
2117 source,
2118 })
2119}
2120
2121pub fn build_read_model(index: &GraphIndex, query: &str) -> ReadResult {
2126 match build_refs_model_inner(
2127 index,
2128 query,
2129 false,
2130 DEFAULT_CAP,
2131 INBOUND_CAP,
2132 OUTBOUND_CAP,
2133 false,
2134 true,
2135 ) {
2136 RefsResult::Resolved(m) => {
2137 let end_line = index.def(&m.id).map(|d| d.end_line).unwrap_or(0);
2138 let span = if end_line > 0 {
2139 read_span(&index.root, &m, end_line)
2140 } else {
2141 None
2142 };
2143 ReadResult::Resolved(Box::new(ReadModel { refs: m, span }))
2144 }
2145 RefsResult::Members(models) => ReadResult::Members(models),
2146 RefsResult::Ambiguous(ids) => ReadResult::Ambiguous(ids),
2147 RefsResult::NotFound => ReadResult::NotFound,
2148 }
2149}
2150
2151#[derive(Debug, Clone, PartialEq)]
2159pub struct TestRow {
2160 pub file: String,
2162 pub test_defs: Vec<String>,
2164 pub lines: Vec<usize>,
2166 pub ref_count: usize,
2168 pub heuristic: bool,
2170}
2171
2172#[derive(Debug, Clone, PartialEq)]
2174pub struct TestsModel {
2175 pub query: String,
2177 pub symbol: String,
2179 pub def_files: Vec<String>,
2181 pub rows: Vec<TestRow>,
2184 pub test_file_count: usize,
2186 pub ref_count: usize,
2188 pub heuristic_file_count: usize,
2190 pub heuristic_ref_count: usize,
2192}
2193
2194#[derive(Debug, Clone, PartialEq)]
2196pub enum TestsResult {
2197 Resolved(TestsModel),
2199 Ambiguous(Vec<String>),
2201 NotFound,
2203}
2204
2205fn collect_test_rows(index: &GraphIndex, kinds: [&[usize]; 3], heuristic: bool) -> Vec<TestRow> {
2207 let mut rows: Vec<TestRow> = Vec::new();
2208 let mut by_file: HashMap<String, usize> = HashMap::new();
2209 for idxs in kinds {
2210 for &i in idxs {
2211 let (from_file, from_line) = edge_loc(&index.graph.edges[i]);
2212 let Some(test_defs) = index.test_defs_by_file.get(from_file) else {
2213 continue;
2214 };
2215 let slot = match by_file.get(from_file) {
2216 Some(&slot) => slot,
2217 None => {
2218 let slot = rows.len();
2219 rows.push(TestRow {
2220 file: from_file.to_string(),
2221 test_defs: test_defs
2222 .iter()
2223 .map(|&d| index.graph.defs[d].id.clone())
2224 .collect(),
2225 lines: Vec::new(),
2226 ref_count: 0,
2227 heuristic,
2228 });
2229 by_file.insert(from_file.to_string(), slot);
2230 slot
2231 }
2232 };
2233 rows[slot].lines.push(from_line);
2234 rows[slot].ref_count += 1;
2235 }
2236 }
2237 for row in &mut rows {
2238 row.lines.sort_unstable();
2239 }
2240 rows
2241}
2242
2243pub fn build_tests_model(index: &GraphIndex, query: &str) -> TestsResult {
2253 let id = match resolve_symbol(index, query) {
2254 Resolution::Resolved(id) => id,
2255 Resolution::Ambiguous(ids) => return TestsResult::Ambiguous(ids),
2256 Resolution::NotFound => return TestsResult::NotFound,
2257 };
2258 let refs = symbol_refs(index, &id);
2259
2260 let precise = collect_test_rows(
2261 index,
2262 [
2263 &refs.inbound_inherits,
2264 &refs.inbound_uses_type,
2265 &refs.inbound_uses_member,
2266 ],
2267 false,
2268 );
2269 let heuristic = collect_test_rows(
2270 index,
2271 [
2272 &refs.heuristic_inbound_inherits,
2273 &refs.heuristic_inbound_uses_type,
2274 &refs.heuristic_inbound_uses_member,
2275 ],
2276 true,
2277 );
2278
2279 let test_file_count = precise.len();
2280 let ref_count: usize = precise.iter().map(|r| r.ref_count).sum();
2281 let heuristic_file_count = heuristic.len();
2282 let heuristic_ref_count: usize = heuristic.iter().map(|r| r.ref_count).sum();
2283
2284 let mut rows = precise;
2285 rows.extend(heuristic);
2286
2287 TestsResult::Resolved(TestsModel {
2288 query: query.to_string(),
2289 symbol: id.clone(),
2290 def_files: def_files(index, &id),
2291 rows,
2292 test_file_count,
2293 ref_count,
2294 heuristic_file_count,
2295 heuristic_ref_count,
2296 })
2297}
2298
2299#[derive(Debug, Clone, Default, PartialEq)]
2310pub struct KindLines {
2311 pub direct: usize,
2313 pub direct_amb: usize,
2315 pub ctor_di: usize,
2317 pub heuristic: usize,
2319 pub iface: usize,
2321}
2322
2323fn note_line(slot: &mut usize, line: usize) {
2327 if line > 0 && (*slot == 0 || line < *slot) {
2328 *slot = line;
2329 }
2330}
2331
2332#[derive(Debug, Clone, Default)]
2333struct Hit {
2334 via_count: u32,
2335 ambiguous_count: u32,
2336 heuristic_count: u32,
2337 symbols: SeqSet<String>,
2338 iface_via: SeqSet<String>,
2341 lines: KindLines,
2343}
2344
2345#[derive(Debug, Clone, PartialEq)]
2347pub struct VisitedEntry {
2348 pub hop: u32,
2350 pub via_count: u32,
2352 pub ambiguous_count: u32,
2354 pub heuristic_count: u32,
2356 pub symbols: Vec<String>,
2358 pub iface_via: Vec<String>,
2360 pub lines: KindLines,
2362 pub infra: bool,
2365}
2366
2367#[derive(Debug, Clone, PartialEq)]
2371pub struct BrakedIface {
2372 pub iface: String,
2374 pub fanin: usize,
2376}
2377
2378#[derive(Debug, Clone, PartialEq)]
2384pub struct BrakedFile {
2385 pub file: String,
2387 pub indegree: usize,
2389}
2390
2391pub struct ImpactWalkResult {
2393 pub visited: SeqMap<VisitedEntry>,
2398 pub fwd_adj: HashMap<String, HashSet<String>>,
2405 pub seed_files: SeqSet<String>,
2407 pub braked: Vec<BrakedIface>,
2409 pub braked_files: Vec<BrakedFile>,
2411}
2412
2413fn add_adj(fwd_adj: &mut HashMap<String, HashSet<String>>, from: &str, to: &str) {
2414 fwd_adj
2415 .entry(from.to_string())
2416 .or_default()
2417 .insert(to.to_string());
2418}
2419
2420pub fn impact_walk(
2437 index: &GraphIndex,
2438 seed_ids: &[String],
2439 hops: u32,
2440 iface: bool,
2441 iface_max_fanin: usize,
2442 hub_max_indegree: usize,
2443) -> ImpactWalkResult {
2444 let mut visited: SeqMap<VisitedEntry> = SeqMap::new();
2445 let mut braked: HashMap<String, usize> = HashMap::new();
2449 let mut braked_files: HashMap<String, usize> = HashMap::new();
2452 let mut seed_files: SeqSet<String> = SeqSet::new();
2453 for id in seed_ids {
2454 for f in def_files(index, id) {
2455 seed_files.insert(f);
2456 }
2457 }
2458
2459 let mut fwd_adj: HashMap<String, HashSet<String>> = HashMap::new();
2460
2461 let mut frontier: SeqSet<String> = SeqSet::new();
2462 for id in seed_ids {
2463 frontier.insert(id.clone());
2464 }
2465 let mut seen_defs: HashSet<String> = seed_ids.iter().cloned().collect();
2466
2467 let mut hop = 1u32;
2468 while hop <= hops && frontier.len() > 0 {
2469 let mut hits: SeqMap<Hit> = SeqMap::new();
2470
2471 for def_id in frontier.iter() {
2472 if let Some(inb) = index.inbound.get(def_id) {
2473 for kind_edges in [&inb.inherits, &inb.uses_type, &inb.uses_member] {
2474 for &ei in kind_edges {
2475 let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2476 let from_file = loc_file.to_string();
2477 {
2478 let h = hits.get_or_insert_default(&from_file);
2479 h.via_count += 1;
2480 h.symbols.insert(def_id.clone());
2481 note_line(&mut h.lines.direct, loc_line);
2482 }
2483 for sf in def_files(index, def_id) {
2484 add_adj(&mut fwd_adj, &sf, &from_file);
2485 }
2486 }
2487 }
2488 }
2489 if let Some(amb_idxs) = index.ambiguous_by_candidate.get(def_id) {
2490 for &ei in amb_idxs {
2491 let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2492 let from_file = loc_file.to_string();
2493 {
2494 let h = hits.get_or_insert_default(&from_file);
2495 h.ambiguous_count += 1;
2496 note_line(&mut h.lines.direct_amb, loc_line);
2497 }
2498 for sf in def_files(index, def_id) {
2499 add_adj(&mut fwd_adj, &sf, &from_file);
2500 }
2501 }
2502 }
2503 if iface {
2510 let mut seen_sites: HashSet<(String, usize)> = HashSet::new();
2511 if let Some(ctor_idxs) = index.ctor_di_by_to.get(def_id) {
2512 for &ei in ctor_idxs {
2513 let graph::Edge::CtorDi {
2514 from_file,
2515 from_line,
2516 iface: iface_name,
2517 ..
2518 } = &index.graph.edges[ei]
2519 else {
2520 continue;
2521 };
2522 if brake_fanin(index, iface_max_fanin, iface_name, &mut braked) {
2526 continue;
2527 }
2528 let from_file = from_file.clone();
2529 {
2530 let h = hits.get_or_insert_default(&from_file);
2531 h.via_count += 1;
2532 h.symbols.insert(def_id.clone());
2533 h.iface_via.insert(format!("{iface_name} (ctor-di)"));
2534 note_line(&mut h.lines.ctor_di, *from_line);
2535 }
2536 seen_sites.insert((from_file.clone(), *from_line));
2537 for sf in def_files(index, def_id) {
2538 add_adj(&mut fwd_adj, &sf, &from_file);
2539 }
2540 }
2541 }
2542 for iface_id in implemented_interfaces(index, def_id) {
2543 let Some(iface_inb) = index.inbound.get(&iface_id) else {
2544 continue;
2545 };
2546 let iface_name = index
2547 .def(&iface_id)
2548 .map(|d| d.name.clone())
2549 .unwrap_or_else(|| iface_id.clone());
2550 if brake_fanin(index, iface_max_fanin, &iface_name, &mut braked) {
2551 continue;
2552 }
2553 for kind_edges in [&iface_inb.uses_type, &iface_inb.uses_member] {
2558 for &ei in kind_edges {
2559 let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2560 let site = (loc_file.to_string(), loc_line);
2561 if seen_sites.contains(&site) {
2562 continue;
2563 }
2564 let from_file = site.0.clone();
2565 seen_sites.insert(site);
2566 {
2567 let h = hits.get_or_insert_default(&from_file);
2568 h.via_count += 1;
2569 h.symbols.insert(def_id.clone());
2570 h.iface_via.insert(iface_name.clone());
2571 note_line(&mut h.lines.iface, loc_line);
2572 }
2573 for sf in def_files(index, def_id) {
2574 add_adj(&mut fwd_adj, &sf, &from_file);
2575 }
2576 }
2577 }
2578 }
2579 }
2580 if let Some(hinb) = index.heuristic_inbound.get(def_id) {
2588 for kind_edges in [&hinb.inherits, &hinb.uses_type, &hinb.uses_member] {
2589 for &ei in kind_edges {
2590 let (loc_file, loc_line) = edge_loc(&index.graph.edges[ei]);
2591 let from_file = loc_file.to_string();
2592 let h = hits.get_or_insert_default(&from_file);
2593 h.heuristic_count += 1;
2594 h.symbols.insert(def_id.clone());
2595 note_line(&mut h.lines.heuristic, loc_line);
2596 }
2597 }
2598 }
2599 }
2600
2601 let mut next_frontier: SeqSet<String> = SeqSet::new();
2602 for (file, h) in hits.iter() {
2603 let indegree = index.hub_indegree.get(file).copied().unwrap_or(0);
2611 let hub = is_infra_file(file) || (hub_max_indegree > 0 && indegree >= hub_max_indegree);
2612 if !visited.contains_key(file) && !seed_files.contains(file) {
2613 visited.insert(
2614 file.clone(),
2615 VisitedEntry {
2616 hop,
2617 via_count: h.via_count,
2618 ambiguous_count: h.ambiguous_count,
2619 heuristic_count: h.heuristic_count,
2620 symbols: h.symbols.clone().into_vec(),
2621 iface_via: h.iface_via.clone().into_vec(),
2622 lines: h.lines.clone(),
2623 infra: hub,
2624 },
2625 );
2626 }
2627 if h.via_count == 0 && h.ambiguous_count == 0 {
2631 continue;
2632 }
2633 if hub {
2641 if hop < hops {
2642 braked_files.insert(file.clone(), indegree);
2643 }
2644 continue;
2645 }
2646 if let Some(def_idxs) = index.by_file.get(file) {
2647 for &di in def_idxs {
2648 let def_id = &index.graph.defs[di].id;
2649 if !seen_defs.contains(def_id) {
2650 seen_defs.insert(def_id.clone());
2651 next_frontier.insert(def_id.clone());
2652 }
2653 }
2654 }
2655 }
2656 frontier = next_frontier;
2657 hop += 1;
2658 }
2659
2660 let mut braked: Vec<BrakedIface> = braked
2664 .into_iter()
2665 .map(|(iface, fanin)| BrakedIface { iface, fanin })
2666 .collect();
2667 braked.sort_by(|a, b| b.fanin.cmp(&a.fanin).then_with(|| a.iface.cmp(&b.iface)));
2668 let mut braked_files: Vec<BrakedFile> = braked_files
2669 .into_iter()
2670 .map(|(file, indegree)| BrakedFile { file, indegree })
2671 .collect();
2672 braked_files.sort_by(|a, b| {
2673 b.indegree
2674 .cmp(&a.indegree)
2675 .then_with(|| a.file.cmp(&b.file))
2676 });
2677
2678 ImpactWalkResult {
2679 visited,
2680 fwd_adj,
2681 seed_files,
2682 braked,
2683 braked_files,
2684 }
2685}
2686
2687fn brake_fanin(
2691 index: &GraphIndex,
2692 iface_max_fanin: usize,
2693 name: &str,
2694 braked: &mut HashMap<String, usize>,
2695) -> bool {
2696 if iface_max_fanin == 0 {
2697 return false;
2698 }
2699 let fanin = index.ctor_di_fanin.get(name).copied().unwrap_or(0);
2700 if fanin <= iface_max_fanin {
2701 return false;
2702 }
2703 braked.insert(name.to_string(), fanin);
2704 true
2705}
2706
2707pub fn personalized_page_rank(
2723 nodes: &[String],
2724 fwd_adj: &HashMap<String, HashSet<String>>,
2725 seeds: &[String],
2726 damping: f64,
2727 iterations: u32,
2728) -> HashMap<String, f64> {
2729 let n = nodes.len();
2730 if n == 0 {
2731 return HashMap::new();
2732 }
2733 let idx: HashMap<&str, usize> = nodes
2734 .iter()
2735 .enumerate()
2736 .map(|(i, s)| (s.as_str(), i))
2737 .collect();
2738
2739 let mut teleport = vec![0f64; n];
2740 let mut wsum = 0f64;
2741 for s in seeds {
2742 if let Some(&i) = idx.get(s.as_str()) {
2743 teleport[i] = 1.0;
2744 wsum += 1.0;
2745 }
2746 }
2747 if wsum == 0.0 {
2748 let v = 1.0 / n as f64;
2749 for t in teleport.iter_mut() {
2750 *t = v;
2751 }
2752 } else {
2753 for t in teleport.iter_mut() {
2754 *t /= wsum;
2755 }
2756 }
2757
2758 let out_lists: Vec<Vec<usize>> = nodes
2759 .iter()
2760 .map(|id| match fwd_adj.get(id) {
2761 None => Vec::new(),
2762 Some(s) => s
2763 .iter()
2764 .filter_map(|t| idx.get(t.as_str()).copied())
2765 .collect(),
2766 })
2767 .collect();
2768
2769 let mut rank = teleport.clone();
2770 for _ in 0..iterations {
2771 let mut next = vec![0f64; n];
2772 for i in 0..n {
2773 let r = rank[i];
2774 if r == 0.0 {
2775 continue;
2776 }
2777 let outs = &out_lists[i];
2778 if outs.is_empty() {
2779 for k in 0..n {
2780 next[k] += damping * r * teleport[k]; }
2782 continue;
2783 }
2784 let share = (damping * r) / outs.len() as f64;
2785 for &j in outs {
2786 next[j] += share;
2787 }
2788 }
2789 for k in 0..n {
2790 next[k] += (1.0 - damping) * teleport[k];
2791 }
2792 rank = next;
2793 }
2794
2795 let mut result = HashMap::with_capacity(n);
2796 for (i, id) in nodes.iter().enumerate() {
2797 result.insert(id.clone(), rank[i]);
2798 }
2799 result
2800}
2801
2802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2807pub enum SeedKind {
2809 File,
2811 Symbol,
2813}
2814
2815pub fn looks_like_file_path(arg: &str) -> bool {
2824 if arg.contains('/') {
2825 return true;
2826 }
2827 match arg.rfind('.') {
2828 Some(pos) => {
2829 let ext = &arg[pos + 1..];
2830 !ext.is_empty() && ext.chars().all(|c| c.is_ascii_alphanumeric())
2831 }
2832 None => false,
2833 }
2834}
2835
2836#[derive(Debug, Clone, PartialEq)]
2837pub enum SeedResolution {
2839 Resolved {
2841 kind: SeedKind,
2843 ids: Vec<String>,
2845 },
2846 Ambiguous {
2848 kind: SeedKind,
2850 ids: Vec<String>,
2852 },
2853 NotFound {
2855 kind: SeedKind,
2857 },
2858}
2859
2860pub fn resolve_impact_seed(index: &GraphIndex, arg: &str) -> SeedResolution {
2862 if looks_like_file_path(arg) {
2863 return match index.by_file.get(arg) {
2864 Some(ids) if !ids.is_empty() => SeedResolution::Resolved {
2865 kind: SeedKind::File,
2866 ids: ids
2867 .iter()
2868 .map(|&i| index.graph.defs[i].id.clone())
2869 .collect(),
2870 },
2871 _ => SeedResolution::NotFound {
2872 kind: SeedKind::File,
2873 },
2874 };
2875 }
2876 match resolve_symbol(index, arg) {
2877 Resolution::Resolved(id) => SeedResolution::Resolved {
2878 kind: SeedKind::Symbol,
2879 ids: vec![id],
2880 },
2881 Resolution::Ambiguous(ids) => SeedResolution::Ambiguous {
2882 kind: SeedKind::Symbol,
2883 ids,
2884 },
2885 Resolution::NotFound => SeedResolution::NotFound {
2886 kind: SeedKind::Symbol,
2887 },
2888 }
2889}
2890
2891#[derive(Debug, Clone, PartialEq)]
2893pub struct ImpactRow {
2894 pub file: String,
2896 pub hop: u32,
2898 pub via_count: u32,
2900 pub ambiguous_count: u32,
2902 pub top_symbols: Vec<String>,
2904 pub top_symbols_more: usize,
2906 pub score: f64,
2908 pub heuristic_count: u32,
2914 pub heuristic: bool,
2916 pub iface_via: Vec<String>,
2919 pub from_lines: Vec<(&'static str, usize)>,
2926 pub infra: bool,
2930}
2931
2932#[derive(Debug, Clone, PartialEq)]
2934pub struct ImpactModel {
2935 pub kind: SeedKind,
2937 pub seed_files: Vec<String>,
2939 pub hops: u32,
2941 pub total_affected: usize,
2943 pub rows: Vec<ImpactRow>,
2945 pub dropped: usize,
2947 pub manifest_gap: usize,
2949 pub heuristic_affected: usize,
2952 pub tests_affected: usize,
2958 pub braked: Vec<BrakedIface>,
2961 pub braked_files: Vec<BrakedFile>,
2963}
2964
2965#[derive(Debug, Clone, PartialEq)]
2967pub enum ImpactResult {
2968 Resolved(ImpactModel),
2970 Ambiguous {
2972 kind: SeedKind,
2974 ids: Vec<String>,
2976 },
2977 NotFound {
2979 kind: SeedKind,
2981 },
2982}
2983
2984fn from_lines_of(lines: &KindLines) -> Vec<(&'static str, usize)> {
2988 let mut out: Vec<(&'static str, usize)> = Vec::new();
2989 let direct = if lines.direct != 0 {
2990 lines.direct
2991 } else {
2992 lines.direct_amb
2993 };
2994 if direct != 0 {
2995 out.push(("direct", direct));
2996 }
2997 if lines.ctor_di != 0 {
2998 out.push(("ctor-di", lines.ctor_di));
2999 }
3000 if lines.heuristic != 0 {
3001 out.push(("heuristic", lines.heuristic));
3002 }
3003 if lines.iface != 0 {
3004 out.push(("iface", lines.iface));
3005 }
3006 out
3007}
3008
3009pub fn build_impact_model(
3013 index: &GraphIndex,
3014 arg: &str,
3015 hops: u32,
3016 cap: usize,
3017 iface: bool,
3018 iface_max_fanin: usize,
3019 hub_max_indegree: usize,
3020) -> ImpactResult {
3021 let (kind, seed_ids) = match resolve_impact_seed(index, arg) {
3022 SeedResolution::Resolved { kind, ids } => (kind, ids),
3023 SeedResolution::Ambiguous { kind, ids } => return ImpactResult::Ambiguous { kind, ids },
3024 SeedResolution::NotFound { kind } => return ImpactResult::NotFound { kind },
3025 };
3026
3027 let walk = impact_walk(
3028 index,
3029 &seed_ids,
3030 hops,
3031 iface,
3032 iface_max_fanin,
3033 hub_max_indegree,
3034 );
3035
3036 let mut node_set: SeqSet<String> = SeqSet::new();
3039 for f in walk.seed_files.iter() {
3040 node_set.insert(f.clone());
3041 }
3042 for f in walk.visited.keys() {
3043 node_set.insert(f.clone());
3044 }
3045 let nodes = node_set.into_vec();
3046 let seeds: Vec<String> = walk.seed_files.iter().cloned().collect();
3047
3048 let rank = personalized_page_rank(
3049 &nodes,
3050 &walk.fwd_adj,
3051 &seeds,
3052 DEFAULT_DAMPING,
3053 DEFAULT_ITERATIONS,
3054 );
3055
3056 let mut rows: Vec<ImpactRow> = walk
3057 .visited
3058 .iter()
3059 .map(|(file, h)| {
3060 let names: Vec<String> = h
3061 .symbols
3062 .iter()
3063 .map(|id| {
3064 index
3065 .def(id)
3066 .map(|d| d.name.clone())
3067 .unwrap_or_else(|| id.clone())
3068 })
3069 .collect();
3070 let top_symbols: Vec<String> = names.iter().take(3).cloned().collect();
3071 let top_symbols_more = names.len().saturating_sub(3);
3072 let heuristic = h.via_count == 0 && h.ambiguous_count == 0;
3073 ImpactRow {
3074 file: file.clone(),
3075 hop: h.hop,
3076 via_count: h.via_count,
3077 ambiguous_count: h.ambiguous_count,
3078 top_symbols,
3079 top_symbols_more,
3080 score: *rank.get(file).unwrap_or(&0.0),
3081 heuristic_count: if heuristic { h.heuristic_count } else { 0 },
3082 heuristic,
3083 iface_via: h.iface_via.clone(),
3084 from_lines: from_lines_of(&h.lines),
3085 infra: h.infra,
3086 }
3087 })
3088 .collect();
3089
3090 rows.sort_by(|a, b| {
3096 a.heuristic
3097 .cmp(&b.heuristic)
3098 .then_with(|| {
3099 b.score
3100 .partial_cmp(&a.score)
3101 .unwrap_or(std::cmp::Ordering::Equal)
3102 })
3103 .then_with(|| a.hop.cmp(&b.hop))
3104 .then_with(|| a.file.cmp(&b.file))
3105 });
3106
3107 let heuristic_affected = rows.iter().filter(|r| r.heuristic).count();
3108 let total_affected = rows.len() - heuristic_affected;
3109 let tests_affected = rows
3110 .iter()
3111 .filter(|r| !r.heuristic && index.test_defs_by_file.contains_key(&r.file))
3112 .count();
3113 let (shown, dropped) = cap_rows(rows, cap);
3114
3115 ImpactResult::Resolved(ImpactModel {
3116 kind,
3117 seed_files: walk.seed_files.into_vec(),
3118 hops,
3119 total_affected,
3120 rows: shown,
3121 dropped,
3122 manifest_gap: index.flagged_files.len(),
3123 heuristic_affected,
3124 tests_affected,
3125 braked: walk.braked,
3126 braked_files: walk.braked_files,
3127 })
3128}
3129
3130#[cfg(test)]
3139mod tests {
3140 use super::*;
3141 use std::fs;
3142 use std::path::PathBuf;
3143 use std::sync::atomic::{AtomicU64, Ordering};
3144 use std::time::{SystemTime, UNIX_EPOCH};
3145
3146 static COUNTER: AtomicU64 = AtomicU64::new(0);
3147
3148 fn temp_repo_root(label: &str) -> PathBuf {
3149 let nanos = SystemTime::now()
3150 .duration_since(UNIX_EPOCH)
3151 .unwrap()
3152 .as_nanos();
3153 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
3154 let dir = std::env::temp_dir().join(format!("scout-query-test-{label}-{nanos}-{n}"));
3155 fs::create_dir_all(dir.join(".git")).unwrap();
3156 dir
3157 }
3158
3159 fn write_manifest_fixture(root: &Path, files: &[&str]) {
3162 let dir = root.join(".git").join("scout");
3163 fs::create_dir_all(&dir).unwrap();
3164 let mut entries = serde_json::Map::new();
3165 for f in files {
3166 entries.insert(
3167 (*f).to_string(),
3168 serde_json::json!({"purpose": "x", "mtime": 1, "source": "ast"}),
3169 );
3170 }
3171 let manifest = serde_json::json!({"built_at_head": "deadbeef", "scoped_dirs": ["."], "entries": entries});
3172 fs::write(
3173 dir.join("manifest.json"),
3174 serde_json::to_string(&manifest).unwrap(),
3175 )
3176 .unwrap();
3177 }
3178
3179 fn dummy_stats() -> graph::Stats {
3180 graph::Stats {
3181 def_count: 0,
3182 file_count: 0,
3183 edges_by_kind: graph::EdgesByKind::default(),
3184 ambiguous_count: 0,
3185 ambiguous_pct: graph::Percent1::zero(),
3186 unresolved_external_count: 0,
3187 heuristic_edge_count: 0,
3188 test_def_count: 0,
3189 ts: None,
3192 }
3193 }
3194
3195 fn make_graph(defs: Vec<graph::Def>, edges: Vec<graph::Edge>) -> graph::Graph {
3196 graph::Graph {
3197 schema_version: 1,
3198 built_at_head: Some("deadbeef".to_string()),
3199 defs,
3200 edges,
3201 stats: dummy_stats(),
3202 names: Vec::new(),
3203 }
3204 }
3205
3206 fn def(
3207 id: &str,
3208 name: &str,
3209 namespace: &str,
3210 kind: &str,
3211 file: &str,
3212 line: usize,
3213 ) -> graph::Def {
3214 graph::Def {
3215 id: id.into(),
3216 name: name.into(),
3217 namespace: namespace.into(),
3218 kind: kind.into(),
3219 file: file.into(),
3220 line,
3221 methods: vec![],
3222 test_methods: vec![],
3223 also_in: vec![],
3224 end_line: 0,
3225 }
3226 }
3227
3228 fn def_also(
3229 id: &str,
3230 name: &str,
3231 namespace: &str,
3232 kind: &str,
3233 file: &str,
3234 line: usize,
3235 also_in: Vec<(&str, usize)>,
3236 ) -> graph::Def {
3237 graph::Def {
3238 id: id.into(),
3239 name: name.into(),
3240 namespace: namespace.into(),
3241 kind: kind.into(),
3242 file: file.into(),
3243 line,
3244 methods: vec![],
3245 test_methods: vec![],
3246 also_in: also_in
3247 .into_iter()
3248 .map(|(f, l)| graph::AlsoIn {
3249 file: f.into(),
3250 line: l,
3251 })
3252 .collect(),
3253 end_line: 0,
3254 }
3255 }
3256
3257 fn inherits(from_file: &str, from_line: usize, to: &str, to_file: &str) -> graph::Edge {
3258 graph::Edge::Inherits {
3259 from_file: from_file.into(),
3260 from_line,
3261 to: to.into(),
3262 to_file: to_file.into(),
3263 heuristic: false,
3264 }
3265 }
3266 fn uses_type(from_file: &str, from_line: usize, to: &str, to_file: &str) -> graph::Edge {
3267 graph::Edge::UsesType {
3268 from_file: from_file.into(),
3269 from_line,
3270 to: to.into(),
3271 to_file: to_file.into(),
3272 heuristic: false,
3273 }
3274 }
3275 fn uses_member(from_file: &str, from_line: usize, to: &str, to_file: &str) -> graph::Edge {
3276 graph::Edge::UsesMember {
3277 from_file: from_file.into(),
3278 from_line,
3279 to: to.into(),
3280 to_file: to_file.into(),
3281 heuristic: false,
3282 }
3283 }
3284 fn heuristic_uses_member(
3287 from_file: &str,
3288 from_line: usize,
3289 to: &str,
3290 to_file: &str,
3291 ) -> graph::Edge {
3292 graph::Edge::UsesMember {
3293 from_file: from_file.into(),
3294 from_line,
3295 to: to.into(),
3296 to_file: to_file.into(),
3297 heuristic: true,
3298 }
3299 }
3300 fn heuristic_uses_type(
3301 from_file: &str,
3302 from_line: usize,
3303 to: &str,
3304 to_file: &str,
3305 ) -> graph::Edge {
3306 graph::Edge::UsesType {
3307 from_file: from_file.into(),
3308 from_line,
3309 to: to.into(),
3310 to_file: to_file.into(),
3311 heuristic: true,
3312 }
3313 }
3314 fn imports(from_file: &str, from_line: usize, target: &str) -> graph::Edge {
3315 graph::Edge::Imports {
3316 from_file: from_file.into(),
3317 from_line,
3318 target: target.into(),
3319 }
3320 }
3321 fn ambiguous(
3322 from_file: &str,
3323 from_line: usize,
3324 raw: &str,
3325 candidates: Vec<(&str, &str)>,
3326 ) -> graph::Edge {
3327 let candidate_count = candidates.len();
3328 graph::Edge::Ambiguous {
3329 origin: "uses-type".into(),
3330 from_file: from_file.into(),
3331 from_line,
3332 raw: raw.into(),
3333 candidates: candidates
3334 .into_iter()
3335 .map(|(id, file)| graph::Candidate {
3336 id: id.into(),
3337 file: file.into(),
3338 })
3339 .collect(),
3340 candidate_count,
3341 }
3342 }
3343
3344 #[test]
3351 fn file_inbound_counts_counts_precise_reference_edges_by_target_file() {
3352 let g = make_graph(
3353 vec![def(
3354 "App.IWidget",
3355 "IWidget",
3356 "App",
3357 "interface",
3358 "Widgets/IWidget.cs",
3359 3,
3360 )],
3361 vec![
3362 inherits("Widgets/Impl/A.cs", 5, "App.IWidget", "Widgets/IWidget.cs"),
3363 uses_type("Consumers/B.cs", 4, "App.IWidget", "Widgets/IWidget.cs"),
3364 uses_member("Consumers/C.cs", 9, "App.IWidget", "Widgets/IWidget.cs"),
3365 ],
3366 );
3367 let counts = file_inbound_counts(&g);
3368 assert_eq!(
3369 counts.get("Widgets/IWidget.cs"),
3370 Some(&3),
3371 "every precise reference kind counts"
3372 );
3373 assert_eq!(counts.len(), 1);
3374 }
3375
3376 #[test]
3377 fn file_inbound_counts_excludes_heuristic_edges_and_non_reference_kinds() {
3378 let g = make_graph(
3379 vec![def(
3380 "App.IWidget",
3381 "IWidget",
3382 "App",
3383 "interface",
3384 "Widgets/IWidget.cs",
3385 3,
3386 )],
3387 vec![
3388 heuristic_uses_type("Guessy/Guesser.cs", 7, "App.IWidget", "Widgets/IWidget.cs"),
3389 heuristic_uses_member("Guessy/Guesser2.cs", 8, "App.IWidget", "Widgets/IWidget.cs"),
3390 imports("Widgets/IWidget.cs", 1, "App.Widgets"),
3392 graph::Edge::CtorDi {
3395 from_file: "App/Program.cs".into(),
3396 from_line: 4,
3397 iface: "App.IWidget".into(),
3398 resolution: "plain".into(),
3399 args: None,
3400 to: None,
3401 candidates: vec![],
3402 },
3403 ambiguous(
3404 "Ambig/User.cs",
3405 6,
3406 "IWidget",
3407 vec![("App.IWidget", "Widgets/IWidget.cs")],
3408 ),
3409 graph::Edge::Import {
3412 from_file: "Consumers/Importer.ts".into(),
3413 from_line: 1,
3414 target: "./widgets".into(),
3415 to_file: "Widgets/IWidget.ts".into(),
3416 via: None,
3417 },
3418 ],
3419 );
3420 assert!(
3421 file_inbound_counts(&g).is_empty(),
3422 "guesses, imports, ctor-di wiring, and ambiguity earn no count"
3423 );
3424 }
3425
3426 #[test]
3427 fn file_inbound_counts_counts_the_ts_reference_kinds() {
3428 let g = make_graph(
3431 vec![def(
3432 "ui.Button",
3433 "Button",
3434 "",
3435 "function",
3436 "src/Button.tsx",
3437 1,
3438 )],
3439 vec![
3440 graph::Edge::Call {
3441 from_file: "src/App.ts".into(),
3442 from_line: 10,
3443 to: "ui.Button".into(),
3444 to_file: "src/Button.tsx".into(),
3445 },
3446 graph::Edge::JsxUse {
3447 from_file: "src/Page.tsx".into(),
3448 from_line: 20,
3449 to: "ui.Button".into(),
3450 to_file: "src/Button.tsx".into(),
3451 },
3452 graph::Edge::Dispatch {
3453 from_file: "src/store.ts".into(),
3454 from_line: 30,
3455 to: "ui.Button".into(),
3456 to_file: "src/Button.tsx".into(),
3457 },
3458 ],
3459 );
3460 let counts = file_inbound_counts(&g);
3461 assert_eq!(counts.get("src/Button.tsx"), Some(&3));
3462 assert!(
3463 !counts.contains_key("src/App.ts"),
3464 "keyed by the TARGET file only"
3465 );
3466 }
3467
3468 #[test]
3469 fn file_inbound_counts_excludes_a_files_references_to_itself() {
3470 let g = make_graph(
3473 vec![def("App.Hub", "Hub", "App", "class", "src/Hub.cs", 3)],
3474 vec![
3475 uses_type("src/Hub.cs", 5, "App.Hub", "src/Hub.cs"),
3476 inherits("src/Hub.cs", 7, "App.Hub", "src/Hub.cs"),
3477 uses_type("src/Other.cs", 9, "App.Hub", "src/Hub.cs"),
3478 ],
3479 );
3480 let counts = file_inbound_counts(&g);
3481 assert_eq!(
3482 counts.get("src/Hub.cs"),
3483 Some(&1),
3484 "self-references earn nothing"
3485 );
3486 assert_eq!(counts.len(), 1);
3487 }
3488
3489 #[test]
3490 fn file_inbound_counts_on_an_edgeless_graph_is_an_empty_map() {
3491 assert!(file_inbound_counts(&make_graph(vec![], vec![])).is_empty());
3492 }
3493
3494 const BASE_MANIFEST_FILES: &[&str] = &[
3497 "Widgets/IWidget.cs",
3498 "Widgets/Impl/WidgetImpl.cs",
3499 "Widgets/Impl/OtherImpl.cs",
3500 "Consumers/TwoHop.cs",
3501 "Consumers/Holder.cs",
3502 "One/Config.cs",
3503 "Two/Config.cs",
3504 "Outer/Container.cs",
3505 "Outer/Container.Extra.cs",
3506 "Three/Consumer.cs",
3507 ];
3509
3510 fn base_fixture_graph() -> graph::Graph {
3511 make_graph(
3512 vec![
3513 def(
3514 "App.Widgets.IWidget",
3515 "IWidget",
3516 "App.Widgets",
3517 "interface",
3518 "Widgets/IWidget.cs",
3519 3,
3520 ),
3521 def(
3522 "App.Widgets.Impl.WidgetImpl",
3523 "WidgetImpl",
3524 "App.Widgets.Impl",
3525 "class",
3526 "Widgets/Impl/WidgetImpl.cs",
3527 5,
3528 ),
3529 def(
3530 "App.Widgets.Impl.OtherImpl",
3531 "OtherImpl",
3532 "App.Widgets.Impl",
3533 "class",
3534 "Widgets/Impl/OtherImpl.cs",
3535 5,
3536 ),
3537 def(
3538 "App.Consumers.TwoHop",
3539 "TwoHop",
3540 "App.Consumers",
3541 "class",
3542 "Consumers/TwoHop.cs",
3543 3,
3544 ),
3545 def(
3546 "App.One.Config",
3547 "Config",
3548 "App.One",
3549 "class",
3550 "One/Config.cs",
3551 1,
3552 ),
3553 def(
3554 "App.Two.Config",
3555 "Config",
3556 "App.Two",
3557 "class",
3558 "Two/Config.cs",
3559 1,
3560 ),
3561 def_also(
3562 "App.Outer.Container",
3563 "Container",
3564 "App.Outer",
3565 "class",
3566 "Outer/Container.cs",
3567 3,
3568 vec![("Outer/Container.Extra.cs", 1)],
3569 ),
3570 def(
3571 "App.Outer.Container+Item",
3572 "Item",
3573 "App.Outer",
3574 "class",
3575 "Outer/Container.cs",
3576 5,
3577 ),
3578 def(
3579 "App.Ghost.NotInManifest",
3580 "NotInManifest",
3581 "App.Ghost",
3582 "class",
3583 "Ghost/NotInManifest.cs",
3584 1,
3585 ),
3586 ],
3587 vec![
3588 inherits(
3589 "Widgets/Impl/WidgetImpl.cs",
3590 5,
3591 "App.Widgets.IWidget",
3592 "Widgets/IWidget.cs",
3593 ),
3594 inherits(
3595 "Widgets/Impl/OtherImpl.cs",
3596 5,
3597 "App.Widgets.IWidget",
3598 "Widgets/IWidget.cs",
3599 ),
3600 uses_type(
3601 "Consumers/Holder.cs",
3602 8,
3603 "App.Widgets.IWidget",
3604 "Widgets/IWidget.cs",
3605 ),
3606 uses_type(
3607 "Consumers/TwoHop.cs",
3608 4,
3609 "App.Widgets.Impl.WidgetImpl",
3610 "Widgets/Impl/WidgetImpl.cs",
3611 ),
3612 imports("Widgets/Impl/WidgetImpl.cs", 1, "App.Widgets"),
3613 ambiguous(
3614 "Three/Consumer.cs",
3615 4,
3616 "Config",
3617 vec![
3618 ("App.One.Config", "One/Config.cs"),
3619 ("App.Two.Config", "Two/Config.cs"),
3620 ],
3621 ),
3622 ],
3623 )
3624 }
3625
3626 fn base_fixture_root() -> PathBuf {
3627 let root = temp_repo_root("base");
3628 write_manifest_fixture(&root, BASE_MANIFEST_FILES);
3629 root
3630 }
3631
3632 fn enum_fixture_graph() -> graph::Graph {
3633 make_graph(
3634 vec![
3635 def(
3636 "App.Enums.PostType",
3637 "PostType",
3638 "App.Enums",
3639 "enum",
3640 "Enums/PostType.cs",
3641 3,
3642 ),
3643 def(
3644 "App.Enums.PostType.Post",
3645 "Post",
3646 "App.Enums",
3647 "enum-member",
3648 "Enums/PostType.cs",
3649 5,
3650 ),
3651 def(
3652 "App.Enums.PostType.Question",
3653 "Question",
3654 "App.Enums",
3655 "enum-member",
3656 "Enums/PostType.cs",
3657 6,
3658 ),
3659 def(
3660 "App.Consumers.Reader",
3661 "Reader",
3662 "App.Consumers",
3663 "class",
3664 "Consumers/Reader.cs",
3665 3,
3666 ),
3667 def(
3668 "App.Consumers.TwoHop",
3669 "TwoHop",
3670 "App.Consumers",
3671 "class",
3672 "Consumers/TwoHop.cs",
3673 3,
3674 ),
3675 ],
3676 vec![
3677 uses_member(
3678 "Consumers/Reader.cs",
3679 8,
3680 "App.Enums.PostType.Question",
3681 "Enums/PostType.cs",
3682 ),
3683 uses_type(
3684 "Consumers/TwoHop.cs",
3685 4,
3686 "App.Consumers.Reader",
3687 "Consumers/Reader.cs",
3688 ),
3689 ],
3690 )
3691 }
3692
3693 fn enum_fixture_root() -> PathBuf {
3694 let root = temp_repo_root("enum");
3695 write_manifest_fixture(
3696 &root,
3697 &[
3698 "Enums/PostType.cs",
3699 "Consumers/Reader.cs",
3700 "Consumers/TwoHop.cs",
3701 ],
3702 );
3703 root
3704 }
3705
3706 #[test]
3709 fn load_graph_index_joins_def_files_against_the_manifest_and_flags_a_graph_file_missing_from_it(
3710 ) {
3711 let graph = base_fixture_graph();
3712 let root = base_fixture_root();
3713 let index = load_graph_index(&graph, &root);
3714 assert!(
3715 index.by_id.contains_key("App.Ghost.NotInManifest"),
3716 "the def must still be indexed, not dropped"
3717 );
3718 assert!(
3719 index.flagged_files.contains("Ghost/NotInManifest.cs"),
3720 "its file must be flagged as missing from the manifest"
3721 );
3722 assert_eq!(
3723 index.flagged_files.len(),
3724 1,
3725 "every other def/edge file is in the manifest and must not be flagged"
3726 );
3727 assert!(index.manifest_present);
3728 }
3729
3730 #[test]
3733 fn resolve_symbol_exact_id_unique_name_case_insensitive_ambiguous_notfound() {
3734 let graph = base_fixture_graph();
3735 let root = base_fixture_root();
3736 let index = load_graph_index(&graph, &root);
3737
3738 assert_eq!(
3739 resolve_symbol(&index, "App.Outer.Container+Item"),
3740 Resolution::Resolved("App.Outer.Container+Item".into())
3741 );
3742 assert_eq!(
3743 resolve_symbol(&index, "IWidget"),
3744 Resolution::Resolved("App.Widgets.IWidget".into())
3745 );
3746 assert_eq!(
3747 resolve_symbol(&index, "iwidget"),
3748 Resolution::Resolved("App.Widgets.IWidget".into()),
3749 "case-insensitive unique match must resolve"
3750 );
3751
3752 match resolve_symbol(&index, "Config") {
3753 Resolution::Ambiguous(mut ids) => {
3754 ids.sort();
3755 assert_eq!(
3756 ids,
3757 vec!["App.One.Config".to_string(), "App.Two.Config".to_string()]
3758 );
3759 }
3760 other => panic!("expected Ambiguous, got {other:?}"),
3761 }
3762
3763 assert_eq!(
3764 resolve_symbol(&index, "NoSuchSymbolAnywhere"),
3765 Resolution::NotFound
3766 );
3767 }
3768
3769 #[test]
3772 fn build_refs_model_def_site_inbound_outbound_grouped_imports_manifest_gap() {
3773 let graph = base_fixture_graph();
3774 let root = base_fixture_root();
3775 let index = load_graph_index(&graph, &root);
3776 let model = match build_refs_model(
3777 &index,
3778 "IWidget",
3779 true,
3780 DEFAULT_CAP,
3781 INBOUND_CAP,
3782 OUTBOUND_CAP,
3783 false,
3784 ) {
3785 RefsResult::Resolved(m) => m,
3786 other => panic!("expected Resolved, got {other:?}"),
3787 };
3788
3789 assert_eq!(model.id, "App.Widgets.IWidget");
3790 assert_eq!(
3791 model.sites,
3792 vec![DefSite {
3793 file: "Widgets/IWidget.cs".into(),
3794 line: 3
3795 }]
3796 );
3797
3798 assert_eq!(model.inbound.inherits.total, 2);
3799 let mut files: Vec<&str> = model
3800 .inbound
3801 .inherits
3802 .rows
3803 .iter()
3804 .map(|r| r.file.as_str())
3805 .collect();
3806 files.sort();
3807 assert_eq!(
3808 files,
3809 vec!["Widgets/Impl/OtherImpl.cs", "Widgets/Impl/WidgetImpl.cs"]
3810 );
3811 assert_eq!(model.inbound.uses_type.total, 1);
3812 assert_eq!(model.inbound.uses_type.rows[0].file, "Consumers/Holder.cs");
3813
3814 assert_eq!(
3816 model
3817 .outbound
3818 .as_ref()
3819 .expect("built with out=true")
3820 .inherits
3821 .total,
3822 0
3823 );
3824 assert_eq!(
3825 model
3826 .outbound
3827 .as_ref()
3828 .expect("built with out=true")
3829 .imports
3830 .total,
3831 0
3832 );
3833
3834 assert_eq!(
3835 model.manifest_gap, 1,
3836 "the one flagged def file must surface in every refs call, not just the loader"
3837 );
3838 }
3839
3840 #[test]
3843 fn build_refs_model_outbound_own_file_and_partial_class_sites() {
3844 let graph = base_fixture_graph();
3845 let root = base_fixture_root();
3846 let index = load_graph_index(&graph, &root);
3847
3848 let impl_model = match build_refs_model(
3849 &index,
3850 "WidgetImpl",
3851 true,
3852 DEFAULT_CAP,
3853 INBOUND_CAP,
3854 OUTBOUND_CAP,
3855 false,
3856 ) {
3857 RefsResult::Resolved(m) => m,
3858 other => panic!("expected Resolved, got {other:?}"),
3859 };
3860 assert_eq!(
3861 impl_model
3862 .outbound
3863 .as_ref()
3864 .expect("built with out=true")
3865 .inherits
3866 .total,
3867 1
3868 );
3869 assert_eq!(
3870 impl_model
3871 .outbound
3872 .as_ref()
3873 .expect("built with out=true")
3874 .inherits
3875 .rows[0]
3876 .to_file,
3877 "Widgets/IWidget.cs"
3878 );
3879 assert_eq!(
3880 impl_model
3881 .outbound
3882 .as_ref()
3883 .expect("built with out=true")
3884 .imports
3885 .total,
3886 1
3887 );
3888 assert_eq!(
3889 impl_model
3890 .outbound
3891 .as_ref()
3892 .expect("built with out=true")
3893 .imports
3894 .rows[0]
3895 .target,
3896 "App.Widgets"
3897 );
3898 assert_eq!(
3899 impl_model.inbound.uses_type.total, 1,
3900 "TwoHop.cs references WidgetImpl"
3901 );
3902
3903 let container = match build_refs_model(
3904 &index,
3905 "App.Outer.Container",
3906 true,
3907 DEFAULT_CAP,
3908 INBOUND_CAP,
3909 OUTBOUND_CAP,
3910 false,
3911 ) {
3912 RefsResult::Resolved(m) => m,
3913 other => panic!("expected Resolved, got {other:?}"),
3914 };
3915 assert_eq!(
3916 container.sites,
3917 vec![
3918 DefSite {
3919 file: "Outer/Container.cs".into(),
3920 line: 3
3921 },
3922 DefSite {
3923 file: "Outer/Container.Extra.cs".into(),
3924 line: 1
3925 }
3926 ]
3927 );
3928 }
3929
3930 #[test]
3933 fn build_refs_model_partial_class_same_file_second_site_no_double_count() {
3934 let graph = make_graph(
3935 vec![def_also(
3936 "App.Split.Combo",
3937 "Combo",
3938 "App.Split",
3939 "class",
3940 "Split/Combo.cs",
3941 3,
3942 vec![("Split/Combo.cs", 20)],
3943 )],
3944 vec![
3945 uses_type("Split/Combo.cs", 5, "App.Split.Combo", "Split/Combo.cs"),
3946 imports("Split/Combo.cs", 1, "System"),
3947 ],
3948 );
3949 let root = temp_repo_root("partial-same-file");
3950 write_manifest_fixture(&root, &["Split/Combo.cs"]);
3951 let index = load_graph_index(&graph, &root);
3952 let model = match build_refs_model(
3953 &index,
3954 "Combo",
3955 true,
3956 DEFAULT_CAP,
3957 INBOUND_CAP,
3958 OUTBOUND_CAP,
3959 false,
3960 ) {
3961 RefsResult::Resolved(m) => m,
3962 other => panic!("expected Resolved, got {other:?}"),
3963 };
3964 assert_eq!(
3965 model
3966 .outbound
3967 .as_ref()
3968 .expect("built with out=true")
3969 .uses_type
3970 .total,
3971 1,
3972 "the single outbound uses-type edge must not be counted twice"
3973 );
3974 assert_eq!(
3975 model
3976 .outbound
3977 .as_ref()
3978 .expect("built with out=true")
3979 .imports
3980 .total,
3981 1,
3982 "the single outbound imports edge must not be counted twice"
3983 );
3984 }
3985
3986 #[test]
3989 fn build_refs_model_ambiguous_edges_never_guessed_into_inbound() {
3990 let graph = base_fixture_graph();
3991 let root = base_fixture_root();
3992 let index = load_graph_index(&graph, &root);
3993 let model = match build_refs_model(
3994 &index,
3995 "App.One.Config",
3996 true,
3997 DEFAULT_CAP,
3998 INBOUND_CAP,
3999 OUTBOUND_CAP,
4000 false,
4001 ) {
4002 RefsResult::Resolved(m) => m,
4003 other => panic!("expected Resolved, got {other:?}"),
4004 };
4005
4006 assert_eq!(model.inbound.inherits.total, 0);
4007 assert_eq!(model.inbound.uses_type.total, 0, "the ambiguous ref to \"Config\" must not be counted as a resolved inbound uses-type hit");
4008 assert_eq!(model.ambiguous.inbound.total, 1);
4009 assert_eq!(model.ambiguous.inbound.rows[0].raw, "Config");
4010 assert_eq!(model.ambiguous.inbound.rows[0].candidate_count, 2);
4011 }
4012
4013 #[test]
4016 fn build_refs_model_ambiguous_query_name_returns_candidates() {
4017 let graph = base_fixture_graph();
4018 let root = base_fixture_root();
4019 let index = load_graph_index(&graph, &root);
4020 match build_refs_model(
4021 &index,
4022 "Config",
4023 true,
4024 DEFAULT_CAP,
4025 INBOUND_CAP,
4026 OUTBOUND_CAP,
4027 false,
4028 ) {
4029 RefsResult::Ambiguous(mut ids) => {
4030 ids.sort();
4031 assert_eq!(
4032 ids,
4033 vec!["App.One.Config".to_string(), "App.Two.Config".to_string()]
4034 );
4035 }
4036 other => panic!("expected Ambiguous, got {other:?}"),
4037 }
4038 }
4039
4040 #[test]
4043 fn build_refs_model_caps_a_table_and_reports_dropped() {
4044 let edges: Vec<graph::Edge> = (0..5)
4045 .map(|i| {
4046 uses_type(
4047 &format!("Consumers/C{i}.cs"),
4048 1,
4049 "App.Hot.Popular",
4050 "Hot/Popular.cs",
4051 )
4052 })
4053 .collect();
4054 let graph = make_graph(
4055 vec![def(
4056 "App.Hot.Popular",
4057 "Popular",
4058 "App.Hot",
4059 "class",
4060 "Hot/Popular.cs",
4061 1,
4062 )],
4063 edges,
4064 );
4065 let root = temp_repo_root("cap-table");
4066 let mut files: Vec<String> = (0..5).map(|i| format!("Consumers/C{i}.cs")).collect();
4067 files.push("Hot/Popular.cs".to_string());
4068 let file_refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
4069 write_manifest_fixture(&root, &file_refs);
4070 let index = load_graph_index(&graph, &root);
4071 let model = match build_refs_model(&index, "Popular", true, 2, 2, OUTBOUND_CAP, false) {
4072 RefsResult::Resolved(m) => m,
4073 other => panic!("expected Resolved, got {other:?}"),
4074 };
4075 assert_eq!(model.inbound.uses_type.total, 5);
4076 assert_eq!(
4077 model.inbound.uses_type.rows.len(),
4078 2,
4079 "must respect the cap"
4080 );
4081 assert_eq!(
4082 model.inbound.uses_type.dropped, 3,
4083 "must always report how many rows were dropped"
4084 );
4085 }
4086
4087 #[test]
4090 fn build_refs_model_outbound_tables_exist_only_when_asked_for() {
4091 let graph = base_fixture_graph();
4092 let root = base_fixture_root();
4093 let index = load_graph_index(&graph, &root);
4094 let resolved = |out: bool| match build_refs_model(
4095 &index,
4096 "WidgetImpl",
4097 out,
4098 DEFAULT_CAP,
4099 INBOUND_CAP,
4100 OUTBOUND_CAP,
4101 false,
4102 ) {
4103 RefsResult::Resolved(m) => m,
4104 other => panic!("expected Resolved, got {other:?}"),
4105 };
4106 assert!(
4107 resolved(false).outbound.is_none(),
4108 "the default model has no outbound tables at all"
4109 );
4110 assert!(resolved(true).outbound.is_some(), "--out brings them back");
4111 }
4112
4113 #[test]
4116 fn build_refs_model_one_inbound_cap_ranked_resolved_then_same_project() {
4117 let graph = make_graph(
4121 vec![def(
4122 "App.Hot.Popular",
4123 "Popular",
4124 "App.Hot",
4125 "class",
4126 "Hot/Popular.cs",
4127 1,
4128 )],
4129 vec![
4130 uses_member("Hot/Near.cs", 3, "App.Hot.Popular", "Hot/Popular.cs"),
4131 heuristic_uses_member("Cold/Guess.cs", 4, "App.Hot.Popular", "Hot/Popular.cs"),
4132 heuristic_uses_member("Hot/Guess.cs", 5, "App.Hot.Popular", "Hot/Popular.cs"),
4133 uses_type("Cold/Far.cs", 6, "App.Hot.Popular", "Hot/Popular.cs"),
4134 uses_type("Hot/AlsoNear.cs", 7, "App.Hot.Popular", "Hot/Popular.cs"),
4135 ],
4136 );
4137 let root = temp_repo_root("inbound-rank");
4138 write_manifest_fixture(
4139 &root,
4140 &[
4141 "Hot/Popular.cs",
4142 "Hot/Near.cs",
4143 "Hot/Guess.cs",
4144 "Hot/AlsoNear.cs",
4145 "Cold/Guess.cs",
4146 "Cold/Far.cs",
4147 ],
4148 );
4149 let index = load_graph_index(&graph, &root);
4150 let model = match build_refs_model(
4151 &index,
4152 "Popular",
4153 false,
4154 DEFAULT_CAP,
4155 3,
4156 OUTBOUND_CAP,
4157 false,
4158 ) {
4159 RefsResult::Resolved(m) => m,
4160 other => panic!("expected Resolved, got {other:?}"),
4161 };
4162
4163 fn files(t: &Table<InboundRow>) -> Vec<&str> {
4164 t.rows.iter().map(|r| r.file.as_str()).collect()
4165 }
4166 assert_eq!(
4167 files(&model.inbound.uses_type),
4168 vec!["Hot/AlsoNear.cs", "Cold/Far.cs"]
4169 );
4170 assert_eq!(files(&model.inbound.uses_member), vec!["Hot/Near.cs"]);
4171 assert_eq!(model.inbound.uses_member.total, 3);
4172 assert_eq!(
4173 model.inbound.uses_member.dropped, 2,
4174 "both guesses lost the budget to the facts"
4175 );
4176 assert_eq!(model.inbound.uses_type.dropped, 0);
4177 }
4178
4179 #[test]
4182 fn build_refs_model_shown_hit_carries_its_trimmed_source_line_and_a_missing_file_carries_none()
4183 {
4184 let graph = make_graph(
4185 vec![def(
4186 "App.Hot.Popular",
4187 "Popular",
4188 "App.Hot",
4189 "class",
4190 "Hot/Popular.cs",
4191 1,
4192 )],
4193 vec![
4194 uses_type("Hot/Real.cs", 2, "App.Hot.Popular", "Hot/Popular.cs"),
4195 uses_type("Hot/Absent.cs", 2, "App.Hot.Popular", "Hot/Popular.cs"),
4196 ],
4197 );
4198 let root = temp_repo_root("source-line");
4199 write_manifest_fixture(&root, &["Hot/Popular.cs", "Hot/Real.cs", "Hot/Absent.cs"]);
4200 std::fs::create_dir_all(root.join("Hot")).expect("fixture dir");
4201 std::fs::write(
4202 root.join("Hot/Real.cs"),
4203 "class Real\n\t{\tpublic Popular P { get; set; }\t}\n",
4204 )
4205 .expect("fixture file");
4206
4207 let index = load_graph_index(&graph, &root);
4208 let model = match build_refs_model(
4209 &index,
4210 "Popular",
4211 false,
4212 DEFAULT_CAP,
4213 INBOUND_CAP,
4214 OUTBOUND_CAP,
4215 false,
4216 ) {
4217 RefsResult::Resolved(m) => m,
4218 other => panic!("expected Resolved, got {other:?}"),
4219 };
4220 let row = |file: &str| {
4221 model
4222 .inbound
4223 .uses_type
4224 .rows
4225 .iter()
4226 .find(|r| r.file == file)
4227 .expect("row")
4228 .clone()
4229 };
4230 assert_eq!(
4231 row("Hot/Real.cs").source,
4232 "{ public Popular P { get; set; } }",
4233 "tabs collapse to single spaces, the indent is trimmed off"
4234 );
4235 assert_eq!(
4236 row("Hot/Absent.cs").source,
4237 "",
4238 "a file that is not on disk yields no line, never a partial one"
4239 );
4240 }
4241
4242 #[test]
4245 fn build_refs_model_one_outbound_cap_ranked_resolved_then_same_project_imports_foreign() {
4246 let graph = make_graph(
4253 vec![
4254 def(
4255 "App.Hot.Consumer",
4256 "Consumer",
4257 "App.Hot",
4258 "class",
4259 "Hot/Consumer.cs",
4260 1,
4261 ),
4262 def(
4263 "App.Hot.Local",
4264 "Local",
4265 "App.Hot",
4266 "class",
4267 "Hot/Local.cs",
4268 1,
4269 ),
4270 def("App.Cold.Far", "Far", "App.Cold", "class", "Cold/Far.cs", 1),
4271 ],
4272 vec![
4273 uses_type("Hot/Consumer.cs", 5, "App.Hot.Local", "Hot/Local.cs"),
4274 imports("Hot/Consumer.cs", 1, "System"),
4275 inherits("Hot/Consumer.cs", 6, "App.Cold.Far", "Cold/Far.cs"),
4276 heuristic_uses_member("Hot/Consumer.cs", 7, "App.Hot.Local", "Hot/Local.cs"),
4277 ],
4278 );
4279 let root = temp_repo_root("outbound-rank");
4280 write_manifest_fixture(&root, &["Hot/Consumer.cs", "Hot/Local.cs", "Cold/Far.cs"]);
4281 let index = load_graph_index(&graph, &root);
4282 let model =
4283 match build_refs_model(&index, "Consumer", true, DEFAULT_CAP, INBOUND_CAP, 2, false) {
4284 RefsResult::Resolved(m) => m,
4285 other => panic!("expected Resolved, got {other:?}"),
4286 };
4287 let ob = model.outbound.as_ref().expect("built with out=true");
4288
4289 assert_eq!(
4290 ob.uses_type.rows.len(),
4291 1,
4292 "the same-project resolved edge spends the shared budget first"
4293 );
4294 assert_eq!(ob.uses_type.rows[0].line, 5);
4295 assert_eq!(
4296 ob.imports.rows.len(),
4297 1,
4298 "imports is never a guess, so it beats both the foreign inherits and the heuristic row"
4299 );
4300 assert_eq!(ob.imports.rows[0].line, 1);
4301 assert_eq!(
4302 ob.inherits.rows.len(),
4303 0,
4304 "the foreign inherits edge lost the shared budget"
4305 );
4306 assert_eq!(ob.inherits.dropped, 1);
4307 assert_eq!(
4308 ob.uses_member.rows.len(),
4309 0,
4310 "the heuristic guess lost the budget too, project notwithstanding"
4311 );
4312 assert_eq!(ob.uses_member.dropped, 1);
4313 assert_eq!(ob.imports.dropped, 0);
4314 assert_eq!(ob.uses_type.dropped, 0);
4315 }
4316
4317 #[test]
4318 fn build_refs_model_shown_outbound_hit_carries_its_trimmed_source_line_and_a_missing_file_carries_none(
4319 ) {
4320 let graph = make_graph(
4321 vec![
4322 def_also(
4323 "App.Hot.Consumer",
4324 "Consumer",
4325 "App.Hot",
4326 "class",
4327 "Hot/Consumer.cs",
4328 1,
4329 vec![("Hot/Consumer.Extra.cs", 1)],
4330 ),
4331 def(
4332 "App.Hot.Local",
4333 "Local",
4334 "App.Hot",
4335 "class",
4336 "Hot/Local.cs",
4337 1,
4338 ),
4339 ],
4340 vec![
4341 uses_type("Hot/Consumer.cs", 2, "App.Hot.Local", "Hot/Local.cs"),
4342 uses_type("Hot/Consumer.Extra.cs", 3, "App.Hot.Local", "Hot/Local.cs"),
4343 ],
4344 );
4345 let root = temp_repo_root("outbound-source-line");
4346 write_manifest_fixture(
4347 &root,
4348 &["Hot/Consumer.cs", "Hot/Consumer.Extra.cs", "Hot/Local.cs"],
4349 );
4350 std::fs::create_dir_all(root.join("Hot")).expect("fixture dir");
4351 std::fs::write(
4352 root.join("Hot/Consumer.cs"),
4353 "x\n\t{\tpublic Local L { get; set; }\t}\n",
4354 )
4355 .expect("fixture file");
4356 let index = load_graph_index(&graph, &root);
4359 let model = match build_refs_model(
4360 &index,
4361 "Consumer",
4362 true,
4363 DEFAULT_CAP,
4364 INBOUND_CAP,
4365 OUTBOUND_CAP,
4366 false,
4367 ) {
4368 RefsResult::Resolved(m) => m,
4369 other => panic!("expected Resolved, got {other:?}"),
4370 };
4371 let rows = &model
4372 .outbound
4373 .as_ref()
4374 .expect("built with out=true")
4375 .uses_type
4376 .rows;
4377 let row = |line: usize| rows.iter().find(|r| r.line == line).expect("row").clone();
4378 assert_eq!(
4379 row(2).source,
4380 "{ public Local L { get; set; } }",
4381 "tabs collapse to single spaces, the indent is trimmed off"
4382 );
4383 assert_eq!(
4384 row(3).source,
4385 "",
4386 "a file that is not on disk yields no line, never a partial one"
4387 );
4388 }
4389
4390 #[test]
4391 fn build_refs_model_all_out_lifts_the_outbound_cap() {
4392 let edges: Vec<graph::Edge> = (0..5)
4393 .map(|i| imports("Hot/Consumer.cs", i + 1, &format!("Ns{i}")))
4394 .collect();
4395 let graph = make_graph(
4396 vec![def(
4397 "App.Hot.Consumer",
4398 "Consumer",
4399 "App.Hot",
4400 "class",
4401 "Hot/Consumer.cs",
4402 1,
4403 )],
4404 edges,
4405 );
4406 let root = temp_repo_root("outbound-all");
4407 write_manifest_fixture(&root, &["Hot/Consumer.cs"]);
4408 let index = load_graph_index(&graph, &root);
4409
4410 let capped =
4411 match build_refs_model(&index, "Consumer", true, DEFAULT_CAP, INBOUND_CAP, 2, false) {
4412 RefsResult::Resolved(m) => m,
4413 other => panic!("expected Resolved, got {other:?}"),
4414 };
4415 let capped_ob = capped.outbound.as_ref().expect("built with out=true");
4416 assert_eq!(
4417 capped_ob.imports.rows.len(),
4418 2,
4419 "must respect the outbound cap"
4420 );
4421 assert_eq!(capped_ob.imports.dropped, 3);
4422
4423 let all =
4424 match build_refs_model(&index, "Consumer", true, DEFAULT_CAP, INBOUND_CAP, 2, true) {
4425 RefsResult::Resolved(m) => m,
4426 other => panic!("expected Resolved, got {other:?}"),
4427 };
4428 let all_ob = all.outbound.as_ref().expect("built with out=true");
4429 assert_eq!(
4430 all_ob.imports.rows.len(),
4431 5,
4432 "--all lifts the outbound cap entirely"
4433 );
4434 assert_eq!(all_ob.imports.dropped, 0);
4435 }
4436
4437 #[test]
4442 fn build_refs_model_all_out_now_lifts_the_inbound_cap_too() {
4443 let files = [
4444 "Cold/ConsumerA.cs",
4445 "Cold/ConsumerB.cs",
4446 "Cold/ConsumerC.cs",
4447 "Cold/ConsumerD.cs",
4448 "Cold/ConsumerE.cs",
4449 ];
4450 let edges: Vec<graph::Edge> = files
4451 .iter()
4452 .map(|f| uses_type(f, 1, "App.Hot.Widget", "Hot/Widget.cs"))
4453 .collect();
4454 let graph = make_graph(
4455 vec![def(
4456 "App.Hot.Widget",
4457 "Widget",
4458 "App.Hot",
4459 "class",
4460 "Hot/Widget.cs",
4461 1,
4462 )],
4463 edges,
4464 );
4465 let root = temp_repo_root("inbound-all");
4466 let mut manifest_files: Vec<&str> = vec!["Hot/Widget.cs"];
4467 manifest_files.extend_from_slice(&files);
4468 write_manifest_fixture(&root, &manifest_files);
4469 let index = load_graph_index(&graph, &root);
4470
4471 let capped =
4472 match build_refs_model(&index, "Widget", false, DEFAULT_CAP, 2, OUTBOUND_CAP, false) {
4473 RefsResult::Resolved(m) => m,
4474 other => panic!("expected Resolved, got {other:?}"),
4475 };
4476 assert_eq!(
4477 capped.inbound.uses_type.rows.len(),
4478 2,
4479 "must respect the inbound cap"
4480 );
4481 assert_eq!(
4482 capped.inbound.uses_type.dropped, 3,
4483 "the other 3 referring files are lost without --all"
4484 );
4485
4486 let all =
4487 match build_refs_model(&index, "Widget", false, DEFAULT_CAP, 2, OUTBOUND_CAP, true) {
4488 RefsResult::Resolved(m) => m,
4489 other => panic!("expected Resolved, got {other:?}"),
4490 };
4491 assert_eq!(
4492 all.inbound.uses_type.rows.len(),
4493 5,
4494 "--all lifts the inbound cap too, not just the outbound one"
4495 );
4496 assert_eq!(all.inbound.uses_type.dropped, 0);
4497 }
4498
4499 #[test]
4502 fn build_impact_model_hop_limit_honored() {
4503 let graph = base_fixture_graph();
4504 let root = base_fixture_root();
4505 let index = load_graph_index(&graph, &root);
4506
4507 let one_hop = match build_impact_model(
4508 &index,
4509 "IWidget",
4510 1,
4511 DEFAULT_CAP,
4512 true,
4513 DEFAULT_IFACE_MAX_FANIN,
4514 DEFAULT_HUB_MAX_INDEGREE,
4515 ) {
4516 ImpactResult::Resolved(m) => m,
4517 other => panic!("expected Resolved, got {other:?}"),
4518 };
4519 let mut files: Vec<&str> = one_hop.rows.iter().map(|r| r.file.as_str()).collect();
4520 files.sort();
4521 assert_eq!(
4522 files,
4523 vec![
4524 "Consumers/Holder.cs",
4525 "Widgets/Impl/OtherImpl.cs",
4526 "Widgets/Impl/WidgetImpl.cs"
4527 ]
4528 );
4529 assert!(
4530 !one_hop.rows.iter().any(|r| r.file == "Consumers/TwoHop.cs"),
4531 "TwoHop.cs is 2 hops away and must not appear at hops=1"
4532 );
4533
4534 let two_hop = match build_impact_model(
4535 &index,
4536 "IWidget",
4537 2,
4538 DEFAULT_CAP,
4539 true,
4540 DEFAULT_IFACE_MAX_FANIN,
4541 DEFAULT_HUB_MAX_INDEGREE,
4542 ) {
4543 ImpactResult::Resolved(m) => m,
4544 other => panic!("expected Resolved, got {other:?}"),
4545 };
4546 let two_hop_row = two_hop
4547 .rows
4548 .iter()
4549 .find(|r| r.file == "Consumers/TwoHop.cs")
4550 .expect("TwoHop.cs must appear at hops=2");
4551 assert_eq!(two_hop_row.hop, 2);
4552 assert_eq!(
4553 two_hop_row.top_symbols,
4554 vec!["WidgetImpl".to_string()],
4555 "reached via WidgetImpl, not IWidget directly"
4556 );
4557 }
4558
4559 #[test]
4562 fn build_impact_model_accepts_a_file_path_seeding_every_def_in_it() {
4563 let graph = base_fixture_graph();
4564 let root = base_fixture_root();
4565 let index = load_graph_index(&graph, &root);
4566 let model = match build_impact_model(
4567 &index,
4568 "Widgets/IWidget.cs",
4569 1,
4570 DEFAULT_CAP,
4571 true,
4572 DEFAULT_IFACE_MAX_FANIN,
4573 DEFAULT_HUB_MAX_INDEGREE,
4574 ) {
4575 ImpactResult::Resolved(m) => m,
4576 other => panic!("expected Resolved, got {other:?}"),
4577 };
4578 assert_eq!(model.kind, SeedKind::File);
4579 assert_eq!(model.seed_files, vec!["Widgets/IWidget.cs".to_string()]);
4580 let mut files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
4581 files.sort();
4582 assert_eq!(
4583 files,
4584 vec![
4585 "Consumers/Holder.cs",
4586 "Widgets/Impl/OtherImpl.cs",
4587 "Widgets/Impl/WidgetImpl.cs"
4588 ]
4589 );
4590 }
4591
4592 #[test]
4595 fn build_impact_model_unknown_file_path_is_reported_not_silently_a_symbol() {
4596 let graph = base_fixture_graph();
4597 let root = base_fixture_root();
4598 let index = load_graph_index(&graph, &root);
4599 match build_impact_model(
4600 &index,
4601 "Nowhere/Missing.cs",
4602 1,
4603 DEFAULT_CAP,
4604 true,
4605 DEFAULT_IFACE_MAX_FANIN,
4606 DEFAULT_HUB_MAX_INDEGREE,
4607 ) {
4608 ImpactResult::NotFound { kind } => assert_eq!(kind, SeedKind::File),
4609 other => panic!("expected NotFound, got {other:?}"),
4610 }
4611 }
4612
4613 #[test]
4616 fn build_impact_model_ranking_orders_direct_ahead_of_indirect_never_drops_below_cap() {
4617 let graph = base_fixture_graph();
4618 let root = base_fixture_root();
4619 let index = load_graph_index(&graph, &root);
4620 let model = match build_impact_model(
4621 &index,
4622 "IWidget",
4623 2,
4624 DEFAULT_CAP,
4625 true,
4626 DEFAULT_IFACE_MAX_FANIN,
4627 DEFAULT_HUB_MAX_INDEGREE,
4628 ) {
4629 ImpactResult::Resolved(m) => m,
4630 other => panic!("expected Resolved, got {other:?}"),
4631 };
4632 let score_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap().score;
4633 let hop_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap().hop;
4634 assert!(
4635 score_of("Widgets/Impl/WidgetImpl.cs") > score_of("Consumers/TwoHop.cs"),
4636 "1-hop dependent must outrank the 2-hop one"
4637 );
4638 assert_eq!(hop_of("Consumers/TwoHop.cs"), 2);
4639 assert_eq!(model.dropped, 0);
4640 assert_eq!(
4641 model.total_affected,
4642 model.rows.len(),
4643 "nothing filtered beyond the (unhit) cap"
4644 );
4645 }
4646
4647 #[test]
4650 fn build_impact_model_ranking_deterministic_across_repeated_runs() {
4651 let graph = base_fixture_graph();
4652 let root = base_fixture_root();
4653 let index = load_graph_index(&graph, &root);
4654 let a: Vec<String> = match build_impact_model(
4655 &index,
4656 "IWidget",
4657 2,
4658 DEFAULT_CAP,
4659 true,
4660 DEFAULT_IFACE_MAX_FANIN,
4661 DEFAULT_HUB_MAX_INDEGREE,
4662 ) {
4663 ImpactResult::Resolved(m) => m.rows.into_iter().map(|r| r.file).collect(),
4664 other => panic!("expected Resolved, got {other:?}"),
4665 };
4666 let b: Vec<String> = match build_impact_model(
4667 &index,
4668 "IWidget",
4669 2,
4670 DEFAULT_CAP,
4671 true,
4672 DEFAULT_IFACE_MAX_FANIN,
4673 DEFAULT_HUB_MAX_INDEGREE,
4674 ) {
4675 ImpactResult::Resolved(m) => m.rows.into_iter().map(|r| r.file).collect(),
4676 other => panic!("expected Resolved, got {other:?}"),
4677 };
4678 assert_eq!(a, b);
4679 }
4680
4681 fn ctor_di_to(
4684 from_file: &str,
4685 from_line: usize,
4686 iface: &str,
4687 resolution: &str,
4688 to: &str,
4689 ) -> graph::Edge {
4690 graph::Edge::CtorDi {
4691 from_file: from_file.into(),
4692 from_line,
4693 iface: iface.into(),
4694 resolution: resolution.into(),
4695 args: None,
4696 to: Some(to.into()),
4697 candidates: vec![],
4698 }
4699 }
4700 fn ctor_di_no_to(
4701 from_file: &str,
4702 from_line: usize,
4703 iface: &str,
4704 resolution: &str,
4705 ) -> graph::Edge {
4706 graph::Edge::CtorDi {
4707 from_file: from_file.into(),
4708 from_line,
4709 iface: iface.into(),
4710 resolution: resolution.into(),
4711 args: None,
4712 to: None,
4713 candidates: vec![],
4714 }
4715 }
4716
4717 const IFACE_HOP_MANIFEST_FILES: &[&str] = &[
4718 "Pay/IPaymentGateway.cs",
4719 "Pay/StripeGateway.cs",
4720 "Pay/OrderService.cs",
4721 "Pay/RefundService.cs",
4722 "Pay/GatewayFactory.cs",
4723 "Pay/GatewayHolder.cs",
4724 "Pay/AuditLogger.cs",
4725 ];
4726
4727 fn iface_hop_fixture_graph() -> graph::Graph {
4738 make_graph(
4739 vec![
4740 def(
4741 "App.Pay.IPaymentGateway",
4742 "IPaymentGateway",
4743 "App.Pay",
4744 "interface",
4745 "Pay/IPaymentGateway.cs",
4746 3,
4747 ),
4748 def(
4749 "App.Pay.StripeGateway",
4750 "StripeGateway",
4751 "App.Pay",
4752 "class",
4753 "Pay/StripeGateway.cs",
4754 3,
4755 ),
4756 def(
4757 "App.Pay.OrderService",
4758 "OrderService",
4759 "App.Pay",
4760 "class",
4761 "Pay/OrderService.cs",
4762 3,
4763 ),
4764 def(
4765 "App.Pay.RefundService",
4766 "RefundService",
4767 "App.Pay",
4768 "class",
4769 "Pay/RefundService.cs",
4770 3,
4771 ),
4772 def(
4773 "App.Pay.GatewayFactory",
4774 "GatewayFactory",
4775 "App.Pay",
4776 "class",
4777 "Pay/GatewayFactory.cs",
4778 3,
4779 ),
4780 def(
4781 "App.Pay.GatewayHolder",
4782 "GatewayHolder",
4783 "App.Pay",
4784 "class",
4785 "Pay/GatewayHolder.cs",
4786 3,
4787 ),
4788 def(
4789 "App.Pay.AuditLogger",
4790 "AuditLogger",
4791 "App.Pay",
4792 "class",
4793 "Pay/AuditLogger.cs",
4794 3,
4795 ),
4796 ],
4797 vec![
4798 inherits(
4799 "Pay/StripeGateway.cs",
4800 3,
4801 "App.Pay.IPaymentGateway",
4802 "Pay/IPaymentGateway.cs",
4803 ),
4804 ctor_di_to(
4805 "Pay/OrderService.cs",
4806 5,
4807 "IPaymentGateway",
4808 "plain",
4809 "App.Pay.StripeGateway",
4810 ),
4811 uses_type(
4812 "Pay/OrderService.cs",
4813 5,
4814 "App.Pay.IPaymentGateway",
4815 "Pay/IPaymentGateway.cs",
4816 ),
4817 ctor_di_to(
4818 "Pay/RefundService.cs",
4819 5,
4820 "IPaymentGateway",
4821 "plain",
4822 "App.Pay.StripeGateway",
4823 ),
4824 uses_type(
4825 "Pay/RefundService.cs",
4826 5,
4827 "App.Pay.IPaymentGateway",
4828 "Pay/IPaymentGateway.cs",
4829 ),
4830 uses_type(
4831 "Pay/GatewayFactory.cs",
4832 6,
4833 "App.Pay.StripeGateway",
4834 "Pay/StripeGateway.cs",
4835 ),
4836 uses_type(
4837 "Pay/GatewayHolder.cs",
4838 4,
4839 "App.Pay.IPaymentGateway",
4840 "Pay/IPaymentGateway.cs",
4841 ),
4842 ctor_di_no_to("Pay/AuditLogger.cs", 5, "ILogger", "infra"),
4843 ],
4844 )
4845 }
4846
4847 fn iface_hop_fixture_root() -> PathBuf {
4848 let root = temp_repo_root("iface-hop");
4849 write_manifest_fixture(&root, IFACE_HOP_MANIFEST_FILES);
4850 root
4851 }
4852
4853 #[test]
4854 fn build_impact_model_widens_through_ctor_injected_interface_consumers_and_direct_interface_references(
4855 ) {
4856 let graph = iface_hop_fixture_graph();
4857 let root = iface_hop_fixture_root();
4858 let index = load_graph_index(&graph, &root);
4859 let model = match build_impact_model(
4860 &index,
4861 "StripeGateway",
4862 1,
4863 DEFAULT_CAP,
4864 true,
4865 DEFAULT_IFACE_MAX_FANIN,
4866 DEFAULT_HUB_MAX_INDEGREE,
4867 ) {
4868 ImpactResult::Resolved(m) => m,
4869 other => panic!("expected Resolved, got {other:?}"),
4870 };
4871 let files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
4872 assert!(
4873 files.contains(&"Pay/GatewayFactory.cs"),
4874 "direct-name reference must still be reached: {files:?}"
4875 );
4876 assert!(
4877 files.contains(&"Pay/OrderService.cs"),
4878 "ctor-injected consumer must be reached: {files:?}"
4879 );
4880 assert!(
4881 files.contains(&"Pay/RefundService.cs"),
4882 "ctor-injected consumer must be reached: {files:?}"
4883 );
4884 assert!(
4885 files.contains(&"Pay/GatewayHolder.cs"),
4886 "direct interface-name reference must be reached: {files:?}"
4887 );
4888 assert!(
4889 !files.contains(&"Pay/AuditLogger.cs"),
4890 "an unrelated infra ctor-di edge must never widen: {files:?}"
4891 );
4892
4893 let row_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap();
4894 assert_eq!(
4895 row_of("Pay/OrderService.cs").hop,
4896 1,
4897 "the interface hop counts as ONE hop, same as a direct reference"
4898 );
4899 assert_eq!(
4900 row_of("Pay/OrderService.cs").iface_via,
4901 vec!["IPaymentGateway (ctor-di)".to_string()]
4902 );
4903 assert_eq!(
4904 row_of("Pay/RefundService.cs").iface_via,
4905 vec!["IPaymentGateway (ctor-di)".to_string()]
4906 );
4907 assert_eq!(
4908 row_of("Pay/GatewayHolder.cs").iface_via,
4909 vec!["IPaymentGateway".to_string()]
4910 );
4911 assert!(
4912 row_of("Pay/GatewayFactory.cs").iface_via.is_empty(),
4913 "a plain direct-name hit carries no iface_via label"
4914 );
4915 assert_eq!(
4918 row_of("Pay/OrderService.cs").via_count,
4919 1,
4920 "the ctor-di hit and its companion ref are ONE hit, not two"
4921 );
4922 }
4923
4924 #[test]
4925 fn build_impact_model_no_iface_restores_the_pre_ds_0050_radius_byte_for_byte() {
4926 let graph = iface_hop_fixture_graph();
4927 let root = iface_hop_fixture_root();
4928 let index = load_graph_index(&graph, &root);
4929 let model = match build_impact_model(
4930 &index,
4931 "StripeGateway",
4932 1,
4933 DEFAULT_CAP,
4934 false,
4935 DEFAULT_IFACE_MAX_FANIN,
4936 DEFAULT_HUB_MAX_INDEGREE,
4937 ) {
4938 ImpactResult::Resolved(m) => m,
4939 other => panic!("expected Resolved, got {other:?}"),
4940 };
4941 let files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
4942 assert_eq!(
4943 files,
4944 vec!["Pay/GatewayFactory.cs"],
4945 "only the direct-name reference survives with --no-iface"
4946 );
4947 assert!(
4948 model.rows.iter().all(|r| r.iface_via.is_empty()),
4949 "no row may carry an iface_via label with --no-iface"
4950 );
4951 }
4952
4953 #[test]
4959 fn build_impact_model_never_widens_through_a_same_named_interface_elsewhere_or_an_ambiguous_ctor_di_edge(
4960 ) {
4961 let graph = make_graph(
4962 vec![
4963 def(
4964 "App.Pay.IPaymentGateway",
4965 "IPaymentGateway",
4966 "App.Pay",
4967 "interface",
4968 "Pay/IPaymentGateway.cs",
4969 3,
4970 ),
4971 def(
4972 "App.Pay.StripeGateway",
4973 "StripeGateway",
4974 "App.Pay",
4975 "class",
4976 "Pay/StripeGateway.cs",
4977 3,
4978 ),
4979 def(
4980 "Other.Billing.IPaymentGateway",
4981 "IPaymentGateway",
4982 "Other.Billing",
4983 "interface",
4984 "Billing/IPaymentGateway.cs",
4985 3,
4986 ),
4987 def(
4988 "Other.Billing.LegacyGateway",
4989 "LegacyGateway",
4990 "Other.Billing",
4991 "class",
4992 "Billing/LegacyGateway.cs",
4993 3,
4994 ),
4995 def(
4996 "App.Pay.UnrelatedConsumer",
4997 "UnrelatedConsumer",
4998 "App.Pay",
4999 "class",
5000 "Pay/UnrelatedConsumer.cs",
5001 3,
5002 ),
5003 ],
5004 vec![
5005 inherits(
5006 "Pay/StripeGateway.cs",
5007 3,
5008 "App.Pay.IPaymentGateway",
5009 "Pay/IPaymentGateway.cs",
5010 ),
5011 inherits(
5012 "Billing/LegacyGateway.cs",
5013 3,
5014 "Other.Billing.IPaymentGateway",
5015 "Billing/IPaymentGateway.cs",
5016 ),
5017 ctor_di_to(
5020 "Pay/UnrelatedConsumer.cs",
5021 5,
5022 "IPaymentGateway",
5023 "plain",
5024 "Other.Billing.LegacyGateway",
5025 ),
5026 ctor_di_no_to(
5029 "Pay/UnrelatedConsumer.cs",
5030 9,
5031 "IPaymentGateway",
5032 "ambiguous",
5033 ),
5034 ],
5035 );
5036 let root = temp_repo_root("iface-hop-collision");
5037 write_manifest_fixture(
5038 &root,
5039 &[
5040 "Pay/IPaymentGateway.cs",
5041 "Pay/StripeGateway.cs",
5042 "Billing/IPaymentGateway.cs",
5043 "Billing/LegacyGateway.cs",
5044 "Pay/UnrelatedConsumer.cs",
5045 ],
5046 );
5047 let index = load_graph_index(&graph, &root);
5048 let model = match build_impact_model(
5049 &index,
5050 "StripeGateway",
5051 1,
5052 DEFAULT_CAP,
5053 true,
5054 DEFAULT_IFACE_MAX_FANIN,
5055 DEFAULT_HUB_MAX_INDEGREE,
5056 ) {
5057 ImpactResult::Resolved(m) => m,
5058 other => panic!("expected Resolved, got {other:?}"),
5059 };
5060 assert!(model.rows.is_empty(), "a same-named interface elsewhere, and an ambiguous ctor-di edge, must never widen: {:?}", model.rows);
5061 }
5062
5063 const BROAD_IFACE_MANIFEST_FILES: &[&str] = &[
5066 "Widgets/IWidgetRepository.cs",
5067 "Widgets/IWidgetClock.cs",
5068 "Widgets/WidgetRepository.cs",
5069 "Widgets/GadgetService.cs",
5070 "Widgets/WidgetConsumer00.cs",
5071 "Widgets/WidgetConsumer01.cs",
5072 "Widgets/WidgetConsumer02.cs",
5073 "Widgets/WidgetConsumer03.cs",
5074 "Widgets/WidgetConsumer04.cs",
5075 "Widgets/WidgetConsumer05.cs",
5076 "Widgets/WidgetConsumer06.cs",
5077 "Widgets/WidgetConsumer07.cs",
5078 "Widgets/WidgetConsumer08.cs",
5079 "Widgets/ClockConsumer0.cs",
5080 "Widgets/ClockConsumer1.cs",
5081 ];
5082
5083 fn broad_iface_fixture_graph() -> graph::Graph {
5091 make_graph(
5092 vec![
5093 def(
5094 "App.Widgets.IWidgetRepository",
5095 "IWidgetRepository",
5096 "App.Widgets",
5097 "interface",
5098 "Widgets/IWidgetRepository.cs",
5099 3,
5100 ),
5101 def(
5102 "App.Widgets.IWidgetClock",
5103 "IWidgetClock",
5104 "App.Widgets",
5105 "interface",
5106 "Widgets/IWidgetClock.cs",
5107 3,
5108 ),
5109 def(
5110 "App.Widgets.WidgetRepository",
5111 "WidgetRepository",
5112 "App.Widgets",
5113 "class",
5114 "Widgets/WidgetRepository.cs",
5115 3,
5116 ),
5117 def(
5118 "App.Widgets.GadgetService",
5119 "GadgetService",
5120 "App.Widgets",
5121 "class",
5122 "Widgets/GadgetService.cs",
5123 3,
5124 ),
5125 def(
5126 "App.Widgets.WidgetConsumer00",
5127 "WidgetConsumer00",
5128 "App.Widgets",
5129 "class",
5130 "Widgets/WidgetConsumer00.cs",
5131 3,
5132 ),
5133 def(
5134 "App.Widgets.WidgetConsumer01",
5135 "WidgetConsumer01",
5136 "App.Widgets",
5137 "class",
5138 "Widgets/WidgetConsumer01.cs",
5139 3,
5140 ),
5141 def(
5142 "App.Widgets.WidgetConsumer02",
5143 "WidgetConsumer02",
5144 "App.Widgets",
5145 "class",
5146 "Widgets/WidgetConsumer02.cs",
5147 3,
5148 ),
5149 def(
5150 "App.Widgets.WidgetConsumer03",
5151 "WidgetConsumer03",
5152 "App.Widgets",
5153 "class",
5154 "Widgets/WidgetConsumer03.cs",
5155 3,
5156 ),
5157 def(
5158 "App.Widgets.WidgetConsumer04",
5159 "WidgetConsumer04",
5160 "App.Widgets",
5161 "class",
5162 "Widgets/WidgetConsumer04.cs",
5163 3,
5164 ),
5165 def(
5166 "App.Widgets.WidgetConsumer05",
5167 "WidgetConsumer05",
5168 "App.Widgets",
5169 "class",
5170 "Widgets/WidgetConsumer05.cs",
5171 3,
5172 ),
5173 def(
5174 "App.Widgets.WidgetConsumer06",
5175 "WidgetConsumer06",
5176 "App.Widgets",
5177 "class",
5178 "Widgets/WidgetConsumer06.cs",
5179 3,
5180 ),
5181 def(
5182 "App.Widgets.WidgetConsumer07",
5183 "WidgetConsumer07",
5184 "App.Widgets",
5185 "class",
5186 "Widgets/WidgetConsumer07.cs",
5187 3,
5188 ),
5189 def(
5190 "App.Widgets.WidgetConsumer08",
5191 "WidgetConsumer08",
5192 "App.Widgets",
5193 "class",
5194 "Widgets/WidgetConsumer08.cs",
5195 3,
5196 ),
5197 def(
5198 "App.Widgets.ClockConsumer0",
5199 "ClockConsumer0",
5200 "App.Widgets",
5201 "class",
5202 "Widgets/ClockConsumer0.cs",
5203 3,
5204 ),
5205 def(
5206 "App.Widgets.ClockConsumer1",
5207 "ClockConsumer1",
5208 "App.Widgets",
5209 "class",
5210 "Widgets/ClockConsumer1.cs",
5211 3,
5212 ),
5213 ],
5214 vec![
5215 inherits(
5216 "Widgets/WidgetRepository.cs",
5217 3,
5218 "App.Widgets.IWidgetRepository",
5219 "Widgets/IWidgetRepository.cs",
5220 ),
5221 inherits(
5222 "Widgets/WidgetRepository.cs",
5223 3,
5224 "App.Widgets.IWidgetClock",
5225 "Widgets/IWidgetClock.cs",
5226 ),
5227 uses_type(
5228 "Widgets/GadgetService.cs",
5229 6,
5230 "App.Widgets.WidgetRepository",
5231 "Widgets/WidgetRepository.cs",
5232 ),
5233 ctor_di_to(
5234 "Widgets/WidgetConsumer00.cs",
5235 5,
5236 "IWidgetRepository",
5237 "plain",
5238 "App.Widgets.WidgetRepository",
5239 ),
5240 uses_type(
5241 "Widgets/WidgetConsumer00.cs",
5242 5,
5243 "App.Widgets.IWidgetRepository",
5244 "Widgets/IWidgetRepository.cs",
5245 ),
5246 ctor_di_to(
5247 "Widgets/WidgetConsumer01.cs",
5248 5,
5249 "IWidgetRepository",
5250 "plain",
5251 "App.Widgets.WidgetRepository",
5252 ),
5253 uses_type(
5254 "Widgets/WidgetConsumer01.cs",
5255 5,
5256 "App.Widgets.IWidgetRepository",
5257 "Widgets/IWidgetRepository.cs",
5258 ),
5259 ctor_di_to(
5260 "Widgets/WidgetConsumer02.cs",
5261 5,
5262 "IWidgetRepository",
5263 "plain",
5264 "App.Widgets.WidgetRepository",
5265 ),
5266 uses_type(
5267 "Widgets/WidgetConsumer02.cs",
5268 5,
5269 "App.Widgets.IWidgetRepository",
5270 "Widgets/IWidgetRepository.cs",
5271 ),
5272 ctor_di_to(
5273 "Widgets/WidgetConsumer03.cs",
5274 5,
5275 "IWidgetRepository",
5276 "plain",
5277 "App.Widgets.WidgetRepository",
5278 ),
5279 uses_type(
5280 "Widgets/WidgetConsumer03.cs",
5281 5,
5282 "App.Widgets.IWidgetRepository",
5283 "Widgets/IWidgetRepository.cs",
5284 ),
5285 ctor_di_to(
5286 "Widgets/WidgetConsumer04.cs",
5287 5,
5288 "IWidgetRepository",
5289 "plain",
5290 "App.Widgets.WidgetRepository",
5291 ),
5292 uses_type(
5293 "Widgets/WidgetConsumer04.cs",
5294 5,
5295 "App.Widgets.IWidgetRepository",
5296 "Widgets/IWidgetRepository.cs",
5297 ),
5298 ctor_di_to(
5299 "Widgets/WidgetConsumer05.cs",
5300 5,
5301 "IWidgetRepository",
5302 "plain",
5303 "App.Widgets.WidgetRepository",
5304 ),
5305 uses_type(
5306 "Widgets/WidgetConsumer05.cs",
5307 5,
5308 "App.Widgets.IWidgetRepository",
5309 "Widgets/IWidgetRepository.cs",
5310 ),
5311 ctor_di_to(
5312 "Widgets/WidgetConsumer06.cs",
5313 5,
5314 "IWidgetRepository",
5315 "plain",
5316 "App.Widgets.WidgetRepository",
5317 ),
5318 uses_type(
5319 "Widgets/WidgetConsumer06.cs",
5320 5,
5321 "App.Widgets.IWidgetRepository",
5322 "Widgets/IWidgetRepository.cs",
5323 ),
5324 ctor_di_to(
5325 "Widgets/WidgetConsumer07.cs",
5326 5,
5327 "IWidgetRepository",
5328 "plain",
5329 "App.Widgets.WidgetRepository",
5330 ),
5331 uses_type(
5332 "Widgets/WidgetConsumer07.cs",
5333 5,
5334 "App.Widgets.IWidgetRepository",
5335 "Widgets/IWidgetRepository.cs",
5336 ),
5337 ctor_di_to(
5338 "Widgets/WidgetConsumer08.cs",
5339 5,
5340 "IWidgetRepository",
5341 "plain",
5342 "App.Widgets.WidgetRepository",
5343 ),
5344 uses_type(
5345 "Widgets/WidgetConsumer08.cs",
5346 5,
5347 "App.Widgets.IWidgetRepository",
5348 "Widgets/IWidgetRepository.cs",
5349 ),
5350 ctor_di_to(
5351 "Widgets/ClockConsumer0.cs",
5352 5,
5353 "IWidgetClock",
5354 "plain",
5355 "App.Widgets.WidgetRepository",
5356 ),
5357 uses_type(
5358 "Widgets/ClockConsumer0.cs",
5359 5,
5360 "App.Widgets.IWidgetClock",
5361 "Widgets/IWidgetClock.cs",
5362 ),
5363 ctor_di_to(
5364 "Widgets/ClockConsumer1.cs",
5365 5,
5366 "IWidgetClock",
5367 "plain",
5368 "App.Widgets.WidgetRepository",
5369 ),
5370 uses_type(
5371 "Widgets/ClockConsumer1.cs",
5372 5,
5373 "App.Widgets.IWidgetClock",
5374 "Widgets/IWidgetClock.cs",
5375 ),
5376 ],
5377 )
5378 }
5379
5380 fn broad_iface_fixture_root() -> PathBuf {
5381 let root = temp_repo_root("broad-iface");
5382 write_manifest_fixture(&root, BROAD_IFACE_MANIFEST_FILES);
5383 root
5384 }
5385
5386 #[test]
5387 fn build_impact_model_brakes_a_broad_interface_by_fan_in_while_a_narrow_one_on_the_same_class_still_hops(
5388 ) {
5389 let graph = broad_iface_fixture_graph();
5390 let root = broad_iface_fixture_root();
5391 let index = load_graph_index(&graph, &root);
5392 assert_eq!(
5393 index.ctor_di_fanin.get("IWidgetRepository"),
5394 Some(&9),
5395 "fan-in counts distinct constructor sites"
5396 );
5397 assert_eq!(index.ctor_di_fanin.get("IWidgetClock"), Some(&2));
5398
5399 let model = match build_impact_model(
5400 &index,
5401 "WidgetRepository",
5402 1,
5403 DEFAULT_CAP,
5404 true,
5405 DEFAULT_IFACE_MAX_FANIN,
5406 DEFAULT_HUB_MAX_INDEGREE,
5407 ) {
5408 ImpactResult::Resolved(m) => m,
5409 other => panic!("expected Resolved, got {other:?}"),
5410 };
5411 let mut files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
5412 files.sort();
5413 assert_eq!(
5414 files,
5415 vec!["Widgets/ClockConsumer0.cs", "Widgets/ClockConsumer1.cs", "Widgets/GadgetService.cs"],
5416 "the broad contract is braked on BOTH widening paths; the narrow one and the direct-name edge are untouched"
5417 );
5418 assert_eq!(
5419 model.braked,
5420 vec![BrakedIface {
5421 iface: "IWidgetRepository".to_string(),
5422 fanin: 9
5423 }],
5424 "the narrowing is reported, never silent"
5425 );
5426 }
5427
5428 #[test]
5429 fn build_impact_model_iface_max_fanin_zero_disables_the_brake_and_restores_the_ds_0050_radius()
5430 {
5431 let graph = broad_iface_fixture_graph();
5432 let root = broad_iface_fixture_root();
5433 let index = load_graph_index(&graph, &root);
5434 let model = match build_impact_model(
5435 &index,
5436 "WidgetRepository",
5437 1,
5438 DEFAULT_CAP,
5439 true,
5440 0,
5441 DEFAULT_HUB_MAX_INDEGREE,
5442 ) {
5443 ImpactResult::Resolved(m) => m,
5444 other => panic!("expected Resolved, got {other:?}"),
5445 };
5446 assert_eq!(
5447 model.rows.len(),
5448 12,
5449 "every ctor-injected consumer of both contracts, plus the direct-name reference"
5450 );
5451 assert!(
5452 model.braked.is_empty(),
5453 "a brake that never fired reports nothing"
5454 );
5455
5456 let tight = match build_impact_model(
5459 &index,
5460 "WidgetRepository",
5461 1,
5462 DEFAULT_CAP,
5463 true,
5464 1,
5465 DEFAULT_HUB_MAX_INDEGREE,
5466 ) {
5467 ImpactResult::Resolved(m) => m,
5468 other => panic!("expected Resolved, got {other:?}"),
5469 };
5470 assert_eq!(
5471 tight
5472 .rows
5473 .iter()
5474 .map(|r| r.file.as_str())
5475 .collect::<Vec<_>>(),
5476 vec!["Widgets/GadgetService.cs"]
5477 );
5478 assert_eq!(
5479 tight.braked,
5480 vec![
5481 BrakedIface {
5482 iface: "IWidgetRepository".to_string(),
5483 fanin: 9
5484 },
5485 BrakedIface {
5486 iface: "IWidgetClock".to_string(),
5487 fanin: 2
5488 },
5489 ]
5490 );
5491 }
5492
5493 #[test]
5494 fn build_impact_model_no_iface_keeps_its_ds_0050_meaning_no_hop_at_all_and_no_brake_report() {
5495 let graph = broad_iface_fixture_graph();
5496 let root = broad_iface_fixture_root();
5497 let index = load_graph_index(&graph, &root);
5498 let model = match build_impact_model(
5499 &index,
5500 "WidgetRepository",
5501 1,
5502 DEFAULT_CAP,
5503 false,
5504 DEFAULT_IFACE_MAX_FANIN,
5505 DEFAULT_HUB_MAX_INDEGREE,
5506 ) {
5507 ImpactResult::Resolved(m) => m,
5508 other => panic!("expected Resolved, got {other:?}"),
5509 };
5510 assert_eq!(
5511 model
5512 .rows
5513 .iter()
5514 .map(|r| r.file.as_str())
5515 .collect::<Vec<_>>(),
5516 vec!["Widgets/GadgetService.cs"],
5517 "the hop is off entirely, so the narrow contract does not widen either"
5518 );
5519 assert!(
5520 model.braked.is_empty(),
5521 "nothing was braked because nothing was attempted"
5522 );
5523 }
5524
5525 fn hub_fixture_graph() -> graph::Graph {
5535 let mut defs = vec![
5536 def(
5537 "App.Core.Widget",
5538 "Widget",
5539 "App.Core",
5540 "class",
5541 "Core/Widget.cs",
5542 3,
5543 ),
5544 def(
5545 "App.Api.Startup",
5546 "Startup",
5547 "App.Api",
5548 "class",
5549 "Api/Startup.cs",
5550 3,
5551 ),
5552 def(
5553 "App.Core.Shared",
5554 "Shared",
5555 "App.Core",
5556 "class",
5557 "Core/Shared.cs",
5558 3,
5559 ),
5560 def(
5561 "App.Core.Plain",
5562 "Plain",
5563 "App.Core",
5564 "class",
5565 "Core/Plain.cs",
5566 3,
5567 ),
5568 def("App.Core.S5", "S5", "App.Core", "class", "Core/S5.cs", 3),
5569 def(
5570 "App.Core.PlainUser",
5571 "PlainUser",
5572 "App.Core",
5573 "class",
5574 "Core/PlainUser.cs",
5575 3,
5576 ),
5577 ];
5578 let mut edges = vec![
5579 uses_type("Api/Startup.cs", 10, "App.Core.Widget", "Core/Widget.cs"),
5580 uses_type("Core/Shared.cs", 10, "App.Core.Widget", "Core/Widget.cs"),
5581 uses_type("Core/Plain.cs", 10, "App.Core.Widget", "Core/Widget.cs"),
5582 heuristic_uses_type("Core/S5.cs", 4, "App.Core.Shared", "Core/Shared.cs"),
5583 uses_type("Core/PlainUser.cs", 4, "App.Core.Plain", "Core/Plain.cs"),
5584 ];
5585 for n in ["A", "B", "C", "D"] {
5586 defs.push(def(
5587 &format!("App.Api.{n}"),
5588 n,
5589 "App.Api",
5590 "class",
5591 &format!("Api/{n}.cs"),
5592 3,
5593 ));
5594 edges.push(uses_type(
5595 &format!("Api/{n}.cs"),
5596 4,
5597 "App.Api.Startup",
5598 "Api/Startup.cs",
5599 ));
5600 }
5601 for n in ["S1", "S2", "S3", "S4"] {
5602 defs.push(def(
5603 &format!("App.Core.{n}"),
5604 n,
5605 "App.Core",
5606 "class",
5607 &format!("Core/{n}.cs"),
5608 3,
5609 ));
5610 edges.push(uses_type(
5611 &format!("Core/{n}.cs"),
5612 4,
5613 "App.Core.Shared",
5614 "Core/Shared.cs",
5615 ));
5616 }
5617 make_graph(defs, edges)
5618 }
5619
5620 fn hub_fixture_root() -> PathBuf {
5621 let root = temp_repo_root("hub-file");
5622 let mut files: Vec<String> = vec![
5623 "Core/Widget.cs".into(),
5624 "Api/Startup.cs".into(),
5625 "Core/Shared.cs".into(),
5626 "Core/Plain.cs".into(),
5627 "Core/PlainUser.cs".into(),
5628 "Core/S5.cs".into(),
5629 ];
5630 for n in ["A", "B", "C", "D"] {
5631 files.push(format!("Api/{n}.cs"));
5632 }
5633 for n in ["S1", "S2", "S3", "S4"] {
5634 files.push(format!("Core/{n}.cs"));
5635 }
5636 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
5637 write_manifest_fixture(&root, &refs);
5638 root
5639 }
5640
5641 fn hub_files_of(model: &ImpactModel) -> Vec<String> {
5642 let mut files: Vec<String> = model.rows.iter().map(|r| r.file.clone()).collect();
5643 files.sort();
5644 files
5645 }
5646
5647 #[test]
5648 fn load_graph_index_hub_indegree_counts_distinct_referring_files_across_both_edge_kinds() {
5649 let graph = hub_fixture_graph();
5650 let root = hub_fixture_root();
5651 let index = load_graph_index(&graph, &root);
5652 assert_eq!(index.hub_indegree.get("Api/Startup.cs").copied(), Some(4));
5653 assert_eq!(
5654 index.hub_indegree.get("Core/Shared.cs").copied(),
5655 Some(5),
5656 "the heuristic referrer counts too -- a hub is reached by either kind"
5657 );
5658 assert_eq!(index.hub_indegree.get("Core/Plain.cs").copied(), Some(1));
5659 assert!(
5660 index.hub_indegree.get("Api/A.cs").is_none(),
5661 "a file nothing references carries no entry at all"
5662 );
5663 }
5664
5665 #[test]
5666 fn build_impact_model_a_hub_file_is_recorded_classed_infra_and_never_expanded_through() {
5667 let graph = hub_fixture_graph();
5668 let root = hub_fixture_root();
5669 let index = load_graph_index(&graph, &root);
5670 let model = match build_impact_model(
5671 &index,
5672 "Widget",
5673 2,
5674 DEFAULT_CAP,
5675 true,
5676 DEFAULT_IFACE_MAX_FANIN,
5677 DEFAULT_HUB_MAX_INDEGREE,
5678 ) {
5679 ImpactResult::Resolved(m) => m,
5680 other => panic!("expected Resolved, got {other:?}"),
5681 };
5682 assert_eq!(
5683 hub_files_of(&model),
5684 vec![
5685 "Api/Startup.cs",
5686 "Core/Plain.cs",
5687 "Core/PlainUser.cs",
5688 "Core/S1.cs",
5689 "Core/S2.cs",
5690 "Core/S3.cs",
5691 "Core/S4.cs",
5692 "Core/S5.cs",
5693 "Core/Shared.cs"
5694 ],
5695 "the entry point is reached but its own four consumers are not"
5696 );
5697 let row_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap();
5698 assert!(
5699 row_of("Api/Startup.cs").infra,
5700 "the row says why the walk stopped there"
5701 );
5702 assert!(
5703 !row_of("Core/Shared.cs").infra,
5704 "an ordinary file carries no class key at all"
5705 );
5706 assert_eq!(
5707 model.braked_files,
5708 vec![BrakedFile {
5709 file: "Api/Startup.cs".to_string(),
5710 indegree: 4
5711 }]
5712 );
5713 assert!(model.braked.is_empty(), "no interface was braked here");
5714 }
5715
5716 #[test]
5717 fn build_impact_model_hub_max_indegree_brakes_a_file_no_name_pattern_matches_and_zero_disables_that_half_only(
5718 ) {
5719 let graph = hub_fixture_graph();
5720 let root = hub_fixture_root();
5721 let index = load_graph_index(&graph, &root);
5722 let tight = match build_impact_model(
5723 &index,
5724 "Widget",
5725 2,
5726 DEFAULT_CAP,
5727 true,
5728 DEFAULT_IFACE_MAX_FANIN,
5729 5,
5730 ) {
5731 ImpactResult::Resolved(m) => m,
5732 other => panic!("expected Resolved, got {other:?}"),
5733 };
5734 assert_eq!(
5735 hub_files_of(&tight),
5736 vec![
5737 "Api/Startup.cs",
5738 "Core/Plain.cs",
5739 "Core/PlainUser.cs",
5740 "Core/Shared.cs"
5741 ],
5742 "the in-degree-5 file stops expanding too"
5743 );
5744 assert_eq!(
5745 tight.braked_files,
5746 vec![
5747 BrakedFile {
5748 file: "Core/Shared.cs".to_string(),
5749 indegree: 5
5750 },
5751 BrakedFile {
5752 file: "Api/Startup.cs".to_string(),
5753 indegree: 4
5754 },
5755 ],
5756 "widest-first, then by path"
5757 );
5758
5759 let off = match build_impact_model(
5760 &index,
5761 "Widget",
5762 2,
5763 DEFAULT_CAP,
5764 true,
5765 DEFAULT_IFACE_MAX_FANIN,
5766 0,
5767 ) {
5768 ImpactResult::Resolved(m) => m,
5769 other => panic!("expected Resolved, got {other:?}"),
5770 };
5771 assert_eq!(
5772 off.braked_files,
5773 vec![BrakedFile { file: "Api/Startup.cs".to_string(), indegree: 4 }],
5774 "0 disables the threshold; the name-pattern classification is not a threshold and stays on"
5775 );
5776 assert!(
5777 hub_files_of(&off).contains(&"Core/S1.cs".to_string()),
5778 "the in-degree hub widens again"
5779 );
5780 assert!(
5781 !hub_files_of(&off).contains(&"Api/A.cs".to_string()),
5782 "an entry point is still an entry point at --hub-max-indegree 0"
5783 );
5784 }
5785
5786 #[test]
5787 fn build_impact_model_a_hub_reached_on_the_last_hop_is_never_reported_as_braked() {
5788 let graph = hub_fixture_graph();
5789 let root = hub_fixture_root();
5790 let index = load_graph_index(&graph, &root);
5791 let model = match build_impact_model(
5792 &index,
5793 "Widget",
5794 1,
5795 DEFAULT_CAP,
5796 true,
5797 DEFAULT_IFACE_MAX_FANIN,
5798 DEFAULT_HUB_MAX_INDEGREE,
5799 ) {
5800 ImpactResult::Resolved(m) => m,
5801 other => panic!("expected Resolved, got {other:?}"),
5802 };
5803 assert_eq!(
5804 hub_files_of(&model),
5805 vec!["Api/Startup.cs", "Core/Plain.cs", "Core/Shared.cs"]
5806 );
5807 assert!(
5808 model.rows.iter().find(|r| r.file == "Api/Startup.cs").unwrap().infra,
5809 "the classification is a fact about the file, not about whether the walk had another hop left"
5810 );
5811 assert!(
5812 model.braked_files.is_empty(),
5813 "no hop remained, so no widening was refused"
5814 );
5815 }
5816
5817 #[test]
5818 fn is_infra_file_matches_the_four_shapes_and_nothing_that_merely_resembles_them() {
5819 for f in [
5820 "src/Program.cs",
5821 "Startup.cs",
5822 "a/CatalogServiceExtensions.cs",
5823 "a/FooServiceCollectionExtensions.cs",
5824 "a/JobQueueRegistration.cs",
5825 "a/DependencyResolution/Wire.cs",
5826 "a/CompositionRootTests.cs",
5827 "a/GroupControllerTestsBase.cs",
5828 "a/ControllerTestBase.cs",
5829 "a/BaseFixture.cs",
5830 ] {
5831 assert!(is_infra_file(f), "{f} must classify as infra");
5832 }
5833 for f in [
5834 "src/ProgramManager.cs",
5835 "src/StartupRunner.cs",
5836 "src/Registrations.cs",
5837 "src/DependencyResolutionHelper.cs",
5838 "src/CompositionRoot.Extra.cs",
5839 "src/Widget.cs",
5840 ] {
5841 assert!(!is_infra_file(f), "{f} must NOT classify as infra");
5842 }
5843 }
5844
5845 const FROM_LINES_MANIFEST_FILES: &[&str] = &[
5848 "Pay/IPaymentGateway.cs",
5849 "Pay/StripeGateway.cs",
5850 "Pay/Mixed.cs",
5851 "Pay/GuessOnly.cs",
5852 ];
5853
5854 fn from_lines_fixture_graph() -> graph::Graph {
5860 make_graph(
5861 vec![
5862 def(
5863 "App.Pay.IPaymentGateway",
5864 "IPaymentGateway",
5865 "App.Pay",
5866 "interface",
5867 "Pay/IPaymentGateway.cs",
5868 3,
5869 ),
5870 def(
5871 "App.Pay.StripeGateway",
5872 "StripeGateway",
5873 "App.Pay",
5874 "class",
5875 "Pay/StripeGateway.cs",
5876 3,
5877 ),
5878 def(
5879 "App.Pay.Mixed",
5880 "Mixed",
5881 "App.Pay",
5882 "class",
5883 "Pay/Mixed.cs",
5884 3,
5885 ),
5886 def(
5887 "App.Pay.GuessOnly",
5888 "GuessOnly",
5889 "App.Pay",
5890 "class",
5891 "Pay/GuessOnly.cs",
5892 3,
5893 ),
5894 ],
5895 vec![
5896 inherits(
5897 "Pay/StripeGateway.cs",
5898 3,
5899 "App.Pay.IPaymentGateway",
5900 "Pay/IPaymentGateway.cs",
5901 ),
5902 uses_type(
5903 "Pay/Mixed.cs",
5904 12,
5905 "App.Pay.StripeGateway",
5906 "Pay/StripeGateway.cs",
5907 ),
5908 uses_type(
5909 "Pay/Mixed.cs",
5910 5,
5911 "App.Pay.StripeGateway",
5912 "Pay/StripeGateway.cs",
5913 ),
5914 ctor_di_to(
5915 "Pay/Mixed.cs",
5916 7,
5917 "IPaymentGateway",
5918 "plain",
5919 "App.Pay.StripeGateway",
5920 ),
5921 uses_type(
5922 "Pay/Mixed.cs",
5923 7,
5924 "App.Pay.IPaymentGateway",
5925 "Pay/IPaymentGateway.cs",
5926 ),
5927 uses_type(
5928 "Pay/Mixed.cs",
5929 20,
5930 "App.Pay.IPaymentGateway",
5931 "Pay/IPaymentGateway.cs",
5932 ),
5933 heuristic_uses_member(
5934 "Pay/Mixed.cs",
5935 30,
5936 "App.Pay.StripeGateway",
5937 "Pay/StripeGateway.cs",
5938 ),
5939 heuristic_uses_member(
5940 "Pay/GuessOnly.cs",
5941 9,
5942 "App.Pay.StripeGateway",
5943 "Pay/StripeGateway.cs",
5944 ),
5945 ],
5946 )
5947 }
5948
5949 fn from_lines_fixture_root() -> PathBuf {
5950 let root = temp_repo_root("from-lines");
5951 write_manifest_fixture(&root, FROM_LINES_MANIFEST_FILES);
5952 root
5953 }
5954
5955 #[test]
5956 fn build_impact_model_names_one_referencing_line_per_edge_kind_lowest_line_per_kind() {
5957 let graph = from_lines_fixture_graph();
5958 let root = from_lines_fixture_root();
5959 let index = load_graph_index(&graph, &root);
5960 let model = match build_impact_model(
5961 &index,
5962 "StripeGateway",
5963 1,
5964 DEFAULT_CAP,
5965 true,
5966 DEFAULT_IFACE_MAX_FANIN,
5967 DEFAULT_HUB_MAX_INDEGREE,
5968 ) {
5969 ImpactResult::Resolved(m) => m,
5970 other => panic!("expected Resolved, got {other:?}"),
5971 };
5972 let row_of = |file: &str| model.rows.iter().find(|r| r.file == file).unwrap();
5973 assert_eq!(
5974 row_of("Pay/Mixed.cs").from_lines,
5975 vec![
5976 ("direct", 5),
5977 ("ctor-di", 7),
5978 ("heuristic", 30),
5979 ("iface", 20)
5980 ],
5981 "key order is the walk's own kind declaration order, never a map iteration"
5982 );
5983 assert_eq!(
5984 row_of("Pay/Mixed.cs").via_count,
5985 4,
5986 "the companion ref at the ctor-di site is still deduped, not a fifth hit"
5987 );
5988 assert_eq!(
5989 row_of("Pay/GuessOnly.cs").from_lines,
5990 vec![("heuristic", 9)]
5991 );
5992 }
5993
5994 #[test]
5995 fn build_impact_model_no_iface_drops_the_two_interface_hop_kinds_and_keeps_the_rest() {
5996 let graph = from_lines_fixture_graph();
5997 let root = from_lines_fixture_root();
5998 let index = load_graph_index(&graph, &root);
5999 let model = match build_impact_model(
6000 &index,
6001 "StripeGateway",
6002 1,
6003 DEFAULT_CAP,
6004 false,
6005 DEFAULT_IFACE_MAX_FANIN,
6006 DEFAULT_HUB_MAX_INDEGREE,
6007 ) {
6008 ImpactResult::Resolved(m) => m,
6009 other => panic!("expected Resolved, got {other:?}"),
6010 };
6011 let row = model
6012 .rows
6013 .iter()
6014 .find(|r| r.file == "Pay/Mixed.cs")
6015 .unwrap();
6016 assert_eq!(
6017 row.from_lines,
6018 vec![("direct", 5), ("heuristic", 30)],
6019 "no hop was attempted, so neither interface kind may claim a line"
6020 );
6021 }
6022
6023 #[test]
6024 fn build_impact_model_an_ambiguous_only_hit_still_names_its_line_under_the_direct_kind() {
6025 let graph = base_fixture_graph();
6026 let root = base_fixture_root();
6027 let index = load_graph_index(&graph, &root);
6028 let model = match build_impact_model(
6029 &index,
6030 "One/Config.cs",
6031 1,
6032 DEFAULT_CAP,
6033 true,
6034 DEFAULT_IFACE_MAX_FANIN,
6035 DEFAULT_HUB_MAX_INDEGREE,
6036 ) {
6037 ImpactResult::Resolved(m) => m,
6038 other => panic!("expected Resolved, got {other:?}"),
6039 };
6040 let row = model
6041 .rows
6042 .iter()
6043 .find(|r| r.file == "Three/Consumer.cs")
6044 .unwrap();
6045 assert_eq!(row.ambiguous_count, 1);
6046 assert_eq!(
6047 row.from_lines,
6048 vec![("direct", 4)],
6049 "refs' own tie-break: an ambiguous site is used only when no resolved one exists"
6050 );
6051 }
6052
6053 #[test]
6054 fn build_impact_model_a_row_no_kind_could_attribute_a_line_to_carries_no_from_lines_at_all() {
6055 let graph = make_graph(
6056 vec![
6057 def("App.A.Seed", "Seed", "App.A", "class", "A/Seed.cs", 1),
6058 def("App.A.User", "User", "App.A", "class", "A/User.cs", 1),
6059 ],
6060 vec![uses_type("A/User.cs", 0, "App.A.Seed", "A/Seed.cs")],
6061 );
6062 let root = temp_repo_root("from-lines-empty");
6063 write_manifest_fixture(&root, &["A/Seed.cs", "A/User.cs"]);
6064 let index = load_graph_index(&graph, &root);
6065 let model = match build_impact_model(
6066 &index,
6067 "Seed",
6068 1,
6069 DEFAULT_CAP,
6070 true,
6071 DEFAULT_IFACE_MAX_FANIN,
6072 DEFAULT_HUB_MAX_INDEGREE,
6073 ) {
6074 ImpactResult::Resolved(m) => m,
6075 other => panic!("expected Resolved, got {other:?}"),
6076 };
6077 assert_eq!(
6078 model
6079 .rows
6080 .iter()
6081 .map(|r| r.file.as_str())
6082 .collect::<Vec<_>>(),
6083 vec!["A/User.cs"]
6084 );
6085 assert!(
6086 model.rows[0].from_lines.is_empty(),
6087 "a line-less edge adds no key, exactly like every other conditional field"
6088 );
6089 }
6090
6091 #[test]
6094 fn resolve_symbol_finds_enum_member_by_id_and_unique_simple_name() {
6095 let graph = enum_fixture_graph();
6096 let root = enum_fixture_root();
6097 let index = load_graph_index(&graph, &root);
6098 assert_eq!(
6099 resolve_symbol(&index, "App.Enums.PostType.Question"),
6100 Resolution::Resolved("App.Enums.PostType.Question".into())
6101 );
6102 assert_eq!(
6103 resolve_symbol(&index, "Question"),
6104 Resolution::Resolved("App.Enums.PostType.Question".into())
6105 );
6106 }
6107
6108 #[test]
6111 fn resolve_symbol_accepts_a_dotted_tail_of_a_def_id_and_refuses_a_shared_one() {
6112 let graph = enum_fixture_graph();
6113 let root = enum_fixture_root();
6114 let index = load_graph_index(&graph, &root);
6115 assert_eq!(
6116 resolve_symbol(&index, "PostType.Question"),
6117 Resolution::Resolved("App.Enums.PostType.Question".into()),
6118 "the spelling a caller reaches for -- never the namespace-qualified id the graph keys it under"
6119 );
6120 assert_eq!(
6121 resolve_symbol(&index, "Enums.PostType"),
6122 Resolution::Resolved("App.Enums.PostType".into()),
6123 "the tail rule is not enum-specific: any dotted suffix of exactly one def id resolves"
6124 );
6125 assert_eq!(
6126 resolve_symbol(&index, "PostType.Missing"),
6127 Resolution::NotFound
6128 );
6129
6130 let two = make_graph(
6131 vec![
6132 def("App.One.Mode", "Mode", "App.One", "enum", "One/Mode.cs", 1),
6133 def(
6134 "App.One.Mode.Fast",
6135 "Fast",
6136 "App.One",
6137 "enum-member",
6138 "One/Mode.cs",
6139 2,
6140 ),
6141 def("App.Two.Mode", "Mode", "App.Two", "enum", "Two/Mode.cs", 1),
6142 def(
6143 "App.Two.Mode.Fast",
6144 "Fast",
6145 "App.Two",
6146 "enum-member",
6147 "Two/Mode.cs",
6148 2,
6149 ),
6150 ],
6151 vec![],
6152 );
6153 let two_root = temp_repo_root("enum-tail-ambiguous");
6154 write_manifest_fixture(&two_root, &["One/Mode.cs", "Two/Mode.cs"]);
6155 let two_index = load_graph_index(&two, &two_root);
6156 assert_eq!(
6157 resolve_symbol(&two_index, "Mode.Fast"),
6158 Resolution::Ambiguous(vec!["App.One.Mode.Fast".into(), "App.Two.Mode.Fast".into()])
6159 );
6160 }
6161
6162 #[test]
6163 fn build_refs_model_on_an_enum_appends_member_refs_in_declaration_order() {
6164 let graph = make_graph(
6165 vec![
6166 def(
6167 "App.Flags.Toggles",
6168 "Toggles",
6169 "App.Flags",
6170 "enum",
6171 "Flags/Toggles.cs",
6172 3,
6173 ),
6174 def(
6175 "App.Flags.Toggles.EnableX",
6176 "EnableX",
6177 "App.Flags",
6178 "enum-member",
6179 "Flags/Toggles.cs",
6180 5,
6181 ),
6182 def(
6183 "App.Flags.Toggles.EnableY",
6184 "EnableY",
6185 "App.Flags",
6186 "enum-member",
6187 "Flags/Toggles.cs",
6188 6,
6189 ),
6190 def(
6191 "App.Flags.Toggles.EnableZ",
6192 "EnableZ",
6193 "App.Flags",
6194 "enum-member",
6195 "Flags/Toggles.cs",
6196 7,
6197 ),
6198 def(
6199 "App.Run.Runner",
6200 "Runner",
6201 "App.Run",
6202 "class",
6203 "Run/Runner.cs",
6204 3,
6205 ),
6206 ],
6207 vec![
6208 uses_type("Run/Runner.cs", 5, "App.Flags.Toggles", "Flags/Toggles.cs"),
6209 uses_member(
6210 "Run/Runner.cs",
6211 6,
6212 "App.Flags.Toggles.EnableY",
6213 "Flags/Toggles.cs",
6214 ),
6215 uses_member(
6216 "Run/Runner.cs",
6217 7,
6218 "App.Flags.Toggles.EnableX",
6219 "Flags/Toggles.cs",
6220 ),
6221 uses_member(
6222 "Run/Runner.cs",
6223 8,
6224 "App.Flags.Toggles.EnableX",
6225 "Flags/Toggles.cs",
6226 ),
6227 ],
6228 );
6229 let root = temp_repo_root("enum-member-refs");
6230 write_manifest_fixture(&root, &["Flags/Toggles.cs", "Run/Runner.cs"]);
6231 let index = load_graph_index(&graph, &root);
6232
6233 let model = match build_refs_model(
6234 &index,
6235 "Toggles",
6236 false,
6237 DEFAULT_CAP,
6238 INBOUND_CAP,
6239 OUTBOUND_CAP,
6240 false,
6241 ) {
6242 RefsResult::Resolved(m) => m,
6243 other => panic!("expected Resolved, got {other:?}"),
6244 };
6245 assert_eq!(
6246 model.member_refs,
6247 Some(MemberRefs {
6248 total: 3,
6249 member_count: 2,
6250 members: vec![
6251 MemberRefEntry { name: "EnableX".into(), count: 2 },
6252 MemberRefEntry { name: "EnableY".into(), count: 1 },
6253 ],
6254 dropped: 0,
6255 }),
6256 "declaration order, never count order, and a member nothing references is left out entirely"
6257 );
6258 assert_eq!(
6259 model.inbound.uses_member.total, 3,
6260 "the existing union is unchanged by the split"
6261 );
6262
6263 let other = match build_refs_model(
6264 &index,
6265 "Runner",
6266 false,
6267 DEFAULT_CAP,
6268 INBOUND_CAP,
6269 OUTBOUND_CAP,
6270 false,
6271 ) {
6272 RefsResult::Resolved(m) => m,
6273 other => panic!("expected Resolved, got {other:?}"),
6274 };
6275 assert_eq!(
6276 other.member_refs, None,
6277 "nothing but an enum carries the field"
6278 );
6279 }
6280
6281 #[test]
6282 fn build_impact_model_on_an_enum_file_reaches_files_that_use_only_its_members() {
6283 let graph = make_graph(
6284 vec![
6285 def(
6286 "App.Flags.Toggles",
6287 "Toggles",
6288 "App.Flags",
6289 "enum",
6290 "Flags/Toggles.cs",
6291 3,
6292 ),
6293 def(
6294 "App.Flags.Toggles.EnableX",
6295 "EnableX",
6296 "App.Flags",
6297 "enum-member",
6298 "Flags/Toggles.cs",
6299 5,
6300 ),
6301 def(
6302 "App.Run.Runner",
6303 "Runner",
6304 "App.Run",
6305 "class",
6306 "Run/Runner.cs",
6307 3,
6308 ),
6309 ],
6310 vec![uses_member(
6311 "Run/Runner.cs",
6312 6,
6313 "App.Flags.Toggles.EnableX",
6314 "Flags/Toggles.cs",
6315 )],
6316 );
6317 let root = temp_repo_root("enum-member-impact");
6318 write_manifest_fixture(&root, &["Flags/Toggles.cs", "Run/Runner.cs"]);
6319 let index = load_graph_index(&graph, &root);
6320 let model = match build_impact_model(
6321 &index,
6322 "Flags/Toggles.cs",
6323 2,
6324 DEFAULT_CAP,
6325 true,
6326 DEFAULT_IFACE_MAX_FANIN,
6327 DEFAULT_HUB_MAX_INDEGREE,
6328 ) {
6329 ImpactResult::Resolved(m) => m,
6330 other => panic!("expected Resolved, got {other:?}"),
6331 };
6332 assert_eq!(
6333 model.rows.iter().map(|r| (r.file.as_str(), r.top_symbols.clone())).collect::<Vec<_>>(),
6334 vec![("Run/Runner.cs", vec!["EnableX".to_string()])],
6335 "the member def is a def of the seed FILE, so a member-only consumer is still in the blast radius"
6336 );
6337 }
6338
6339 #[test]
6342 fn build_refs_model_on_enum_member_def_site_plus_inbound_uses_member() {
6343 let graph = enum_fixture_graph();
6344 let root = enum_fixture_root();
6345 let index = load_graph_index(&graph, &root);
6346 let model = match build_refs_model(
6347 &index,
6348 "Question",
6349 true,
6350 DEFAULT_CAP,
6351 INBOUND_CAP,
6352 OUTBOUND_CAP,
6353 false,
6354 ) {
6355 RefsResult::Resolved(m) => m,
6356 other => panic!("expected Resolved, got {other:?}"),
6357 };
6358 assert_eq!(model.kind, "enum-member");
6359 assert_eq!(
6360 model.sites,
6361 vec![DefSite {
6362 file: "Enums/PostType.cs".into(),
6363 line: 6
6364 }]
6365 );
6366 assert_eq!(model.inbound.uses_member.total, 1);
6367 assert_eq!(
6368 model.inbound.uses_member.rows[0].file,
6369 "Consumers/Reader.cs"
6370 );
6371 assert_eq!(model.inbound.uses_member.rows[0].line, 8);
6372 }
6373
6374 #[test]
6377 fn build_refs_model_on_enum_itself_unions_members_inbound_uses_member() {
6378 let graph = enum_fixture_graph();
6379 let root = enum_fixture_root();
6380 let index = load_graph_index(&graph, &root);
6381 let model = match build_refs_model(
6382 &index,
6383 "PostType",
6384 true,
6385 DEFAULT_CAP,
6386 INBOUND_CAP,
6387 OUTBOUND_CAP,
6388 false,
6389 ) {
6390 RefsResult::Resolved(m) => m,
6391 other => panic!("expected Resolved, got {other:?}"),
6392 };
6393 assert_eq!(model.kind, "enum");
6394 assert_eq!(
6395 model.inbound.uses_member.total, 1,
6396 "member-level access must surface on the enum query"
6397 );
6398 assert_eq!(
6399 model.inbound.uses_member.rows[0].file,
6400 "Consumers/Reader.cs"
6401 );
6402 assert_eq!(model.inbound.uses_member.rows[0].line, 8);
6403 }
6404
6405 #[test]
6408 fn two_enums_with_same_named_member_resolve_ambiguous_both_sites_surfaced() {
6409 let graph = make_graph(
6410 vec![
6411 def(
6412 "App.One.StatusEnum",
6413 "StatusEnum",
6414 "App.One",
6415 "enum",
6416 "One/StatusEnum.cs",
6417 1,
6418 ),
6419 def(
6420 "App.One.StatusEnum.Changed",
6421 "Changed",
6422 "App.One",
6423 "enum-member",
6424 "One/StatusEnum.cs",
6425 2,
6426 ),
6427 def(
6428 "App.Two.OtherEnum",
6429 "OtherEnum",
6430 "App.Two",
6431 "enum",
6432 "Two/OtherEnum.cs",
6433 1,
6434 ),
6435 def(
6436 "App.Two.OtherEnum.Changed",
6437 "Changed",
6438 "App.Two",
6439 "enum-member",
6440 "Two/OtherEnum.cs",
6441 2,
6442 ),
6443 ],
6444 vec![],
6445 );
6446 let root = temp_repo_root("two-enums-changed");
6447 write_manifest_fixture(&root, &["One/StatusEnum.cs", "Two/OtherEnum.cs"]);
6448 let index = load_graph_index(&graph, &root);
6449
6450 match resolve_symbol(&index, "Changed") {
6451 Resolution::Ambiguous(mut ids) => {
6452 ids.sort();
6453 assert_eq!(
6454 ids,
6455 vec![
6456 "App.One.StatusEnum.Changed".to_string(),
6457 "App.Two.OtherEnum.Changed".to_string()
6458 ]
6459 );
6460 }
6461 other => panic!("expected Ambiguous, got {other:?}"),
6462 }
6463
6464 match build_refs_model(
6465 &index,
6466 "Changed",
6467 true,
6468 DEFAULT_CAP,
6469 INBOUND_CAP,
6470 OUTBOUND_CAP,
6471 false,
6472 ) {
6473 RefsResult::Ambiguous(mut ids) => {
6474 ids.sort();
6475 assert_eq!(
6476 ids,
6477 vec![
6478 "App.One.StatusEnum.Changed".to_string(),
6479 "App.Two.OtherEnum.Changed".to_string()
6480 ]
6481 );
6482 }
6483 other => panic!("expected Ambiguous, got {other:?}"),
6484 }
6485 }
6486
6487 #[test]
6490 fn build_impact_model_uses_member_edge_counts_toward_one_hop_blast_radius() {
6491 let graph = enum_fixture_graph();
6492 let root = enum_fixture_root();
6493 let index = load_graph_index(&graph, &root);
6494 let model = match build_impact_model(
6495 &index,
6496 "Question",
6497 1,
6498 DEFAULT_CAP,
6499 true,
6500 DEFAULT_IFACE_MAX_FANIN,
6501 DEFAULT_HUB_MAX_INDEGREE,
6502 ) {
6503 ImpactResult::Resolved(m) => m,
6504 other => panic!("expected Resolved, got {other:?}"),
6505 };
6506 assert_eq!(
6507 model
6508 .rows
6509 .iter()
6510 .map(|r| r.file.clone())
6511 .collect::<Vec<_>>(),
6512 vec!["Consumers/Reader.cs".to_string()]
6513 );
6514 assert_eq!(model.rows[0].hop, 1);
6515 }
6516
6517 #[test]
6520 fn build_impact_model_uses_member_edge_propagates_second_hop() {
6521 let graph = enum_fixture_graph();
6522 let root = enum_fixture_root();
6523 let index = load_graph_index(&graph, &root);
6524 let model = match build_impact_model(
6525 &index,
6526 "Question",
6527 2,
6528 DEFAULT_CAP,
6529 true,
6530 DEFAULT_IFACE_MAX_FANIN,
6531 DEFAULT_HUB_MAX_INDEGREE,
6532 ) {
6533 ImpactResult::Resolved(m) => m,
6534 other => panic!("expected Resolved, got {other:?}"),
6535 };
6536 let mut files: Vec<&str> = model.rows.iter().map(|r| r.file.as_str()).collect();
6537 files.sort();
6538 assert_eq!(files, vec!["Consumers/Reader.cs", "Consumers/TwoHop.cs"]);
6539 assert_eq!(
6540 model
6541 .rows
6542 .iter()
6543 .find(|r| r.file == "Consumers/TwoHop.cs")
6544 .unwrap()
6545 .hop,
6546 2
6547 );
6548 }
6549
6550 #[test]
6553 fn impact_walk_and_ppr_never_negative_or_nan() {
6554 let graph = base_fixture_graph();
6555 let root = base_fixture_root();
6556 let index = load_graph_index(&graph, &root);
6557 let walk = impact_walk(
6558 &index,
6559 &["App.Widgets.IWidget".to_string()],
6560 2,
6561 true,
6562 DEFAULT_IFACE_MAX_FANIN,
6563 DEFAULT_HUB_MAX_INDEGREE,
6564 );
6565 let mut nodes: SeqSet<String> = SeqSet::new();
6566 for f in walk.seed_files.iter() {
6567 nodes.insert(f.clone());
6568 }
6569 for f in walk.visited.keys() {
6570 nodes.insert(f.clone());
6571 }
6572 let seeds: Vec<String> = walk.seed_files.iter().cloned().collect();
6573 let rank = personalized_page_rank(
6574 &nodes.into_vec(),
6575 &walk.fwd_adj,
6576 &seeds,
6577 DEFAULT_DAMPING,
6578 DEFAULT_ITERATIONS,
6579 );
6580 for v in rank.values() {
6581 assert!(
6582 v.is_finite() && *v >= 0.0,
6583 "every rank must be a finite, non-negative number, got {v}"
6584 );
6585 }
6586 }
6587
6588 #[test]
6592 fn looks_like_file_path_matches_js_regex_semantics() {
6593 assert!(looks_like_file_path("Widgets/IWidget.cs"));
6594 assert!(looks_like_file_path("IWidget.cs"));
6595 assert!(!looks_like_file_path("IWidget"));
6596 assert!(
6597 looks_like_file_path("App.Widgets.IWidget"),
6598 "qualified id with alnum trailing segment matches the regex, same as JS"
6599 );
6600 assert!(
6601 !looks_like_file_path("Foo."),
6602 "trailing dot with nothing after it does not match (empty extension)"
6603 );
6604 assert!(
6605 !looks_like_file_path("Foo!"),
6606 "no dot-then-alnum-to-end anywhere"
6607 );
6608 }
6609
6610 fn widget_def() -> graph::Def {
6624 graph::Def {
6625 id: "App.Core.Widget".into(),
6626 name: "Widget".into(),
6627 namespace: "App.Core".into(),
6628 kind: "class".into(),
6629 file: "Core/Widget.cs".into(),
6630 line: 3,
6631 methods: vec!["Render".to_string()],
6632 test_methods: vec![],
6633 also_in: vec![],
6634 end_line: 0,
6635 }
6636 }
6637
6638 fn stage4_root(defs: &[graph::Def], edges: &[graph::Edge], label: &str) -> PathBuf {
6641 let root = temp_repo_root(label);
6642 let mut files: Vec<String> = Vec::new();
6643 for d in defs {
6644 if !files.contains(&d.file) {
6645 files.push(d.file.clone());
6646 }
6647 }
6648 for e in edges {
6649 let f = edge_loc(e).0.to_string();
6650 if !files.contains(&f) {
6651 files.push(f);
6652 }
6653 }
6654 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
6655 write_manifest_fixture(&root, &refs);
6656 root
6657 }
6658
6659 #[test]
6660 fn stage4_cli_refs_lists_every_precise_row_before_any_heuristic_row_and_suffixes_only_the_guesses(
6661 ) {
6662 let defs = vec![widget_def()];
6666 let edges = vec![
6667 uses_member(
6668 "Consumers/Precise.cs",
6669 10,
6670 "App.Core.Widget",
6671 "Core/Widget.cs",
6672 ),
6673 heuristic_uses_member("Consumers/Guess.cs", 7, "App.Core.Widget", "Core/Widget.cs"),
6674 ];
6675 let root = stage4_root(&defs, &edges, "stage4-order");
6676 let g = make_graph(defs, edges);
6677 let index = load_graph_index(&g, &root);
6678 let model = match build_refs_model(
6679 &index,
6680 "Widget",
6681 true,
6682 DEFAULT_CAP,
6683 INBOUND_CAP,
6684 OUTBOUND_CAP,
6685 false,
6686 ) {
6687 RefsResult::Resolved(m) => m,
6688 other => panic!("expected a resolved model, got {other:?}"),
6689 };
6690
6691 let out = crate::render::render_refs_text(&model);
6692 let lines: Vec<&str> = out.lines().collect();
6693 let start = lines
6694 .iter()
6695 .position(|l| *l == " uses-member (2):")
6696 .expect("a uses-member block counting both rows");
6697 assert_eq!(
6698 &lines[start + 1..start + 3],
6699 [
6700 " Consumers/Precise.cs:10 uses-member",
6701 " Consumers/Guess.cs:7 uses-member (heuristic)"
6702 ]
6703 );
6704
6705 let compact = crate::render::render_refs_compact(&model);
6707 assert!(
6708 compact.contains(
6709 "in:uses-member (2):\n Consumers/Precise.cs:10\n Consumers/Guess.cs:7h"
6710 ),
6711 "{compact}"
6712 );
6713 }
6714
6715 #[test]
6716 fn stage4_cli_refs_precise_rows_filling_the_cap_leave_no_room_for_heuristic_rows() {
6717 let defs = vec![widget_def()];
6718 let mut edges: Vec<graph::Edge> = (0..31)
6719 .map(|i| {
6720 uses_member(
6721 &format!("Consumers/C{i:02}.cs"),
6722 4,
6723 "App.Core.Widget",
6724 "Core/Widget.cs",
6725 )
6726 })
6727 .collect();
6728 edges.push(heuristic_uses_member(
6729 "Consumers/Zzz.cs",
6730 9,
6731 "App.Core.Widget",
6732 "Core/Widget.cs",
6733 ));
6734 let root = stage4_root(&defs, &edges, "stage4-cap");
6735 let g = make_graph(defs, edges);
6736 let index = load_graph_index(&g, &root);
6737 let model = match build_refs_model(
6738 &index,
6739 "Widget",
6740 true,
6741 DEFAULT_CAP,
6742 INBOUND_CAP,
6743 OUTBOUND_CAP,
6744 false,
6745 ) {
6746 RefsResult::Resolved(m) => m,
6747 other => panic!("expected a resolved model, got {other:?}"),
6748 };
6749
6750 let out = crate::render::render_refs_text(&model);
6751 assert!(
6752 out.contains(" uses-member (32, 2 dropped):"),
6753 "the cap is shared: 32 rows, 30 shown, 2 dropped\n{out}"
6754 );
6755 assert!(
6756 out.contains("\n +2 more\n"),
6757 "one trailer carries the exact count the call did not return\n{out}"
6758 );
6759 assert!(
6760 !out.contains("(heuristic)"),
6761 "precise rows have priority -- a full cap shows zero guesses"
6762 );
6763 assert_eq!(
6764 out.lines()
6765 .filter(|l| l.starts_with(" Consumers/"))
6766 .count(),
6767 30
6768 );
6769 }
6770
6771 fn stage4_impact_defs() -> Vec<graph::Def> {
6772 vec![
6773 widget_def(),
6774 def(
6775 "App.Direct.Direct",
6776 "Direct",
6777 "App.Direct",
6778 "class",
6779 "Consumers/Direct.cs",
6780 3,
6781 ),
6782 def(
6783 "App.Guessed.Guessed",
6784 "Guessed",
6785 "App.Guessed",
6786 "class",
6787 "Consumers/Guessed.cs",
6788 3,
6789 ),
6790 ]
6791 }
6792
6793 #[test]
6794 fn stage4_cli_impact_declares_heuristic_reached_files_beside_the_affected_count_only_when_there_are_some(
6795 ) {
6796 let precise = uses_type(
6797 "Consumers/Direct.cs",
6798 4,
6799 "App.Core.Widget",
6800 "Core/Widget.cs",
6801 );
6802
6803 let defs = stage4_impact_defs();
6804 let edges = vec![
6805 precise.clone(),
6806 heuristic_uses_member(
6807 "Consumers/Guessed.cs",
6808 8,
6809 "App.Core.Widget",
6810 "Core/Widget.cs",
6811 ),
6812 ];
6813 let root = stage4_root(&defs, &edges, "stage4-impact-with");
6814 let g = make_graph(defs, edges);
6815 let index = load_graph_index(&g, &root);
6816 let model = match build_impact_model(
6817 &index,
6818 "Core/Widget.cs",
6819 DEFAULT_HOPS,
6820 DEFAULT_CAP,
6821 true,
6822 DEFAULT_IFACE_MAX_FANIN,
6823 DEFAULT_HUB_MAX_INDEGREE,
6824 ) {
6825 ImpactResult::Resolved(m) => m,
6826 other => panic!("expected a resolved model, got {other:?}"),
6827 };
6828 let out = crate::render::render_impact_text("Core/Widget.cs", &model);
6829 assert!(
6830 out.contains("affected files: 1 (+1 heuristic) shown: 2 dropped: 0"),
6831 "{out}"
6832 );
6833 let lines: Vec<&str> = out.lines().collect();
6834 let header = lines
6835 .iter()
6836 .position(|l| *l == "file hops via top-symbols")
6837 .expect("row header present");
6838 assert_eq!(
6839 &lines[header + 1..header + 3],
6840 [
6841 "Consumers/Direct.cs 1 1 Widget",
6842 "Consumers/Guessed.cs 1 1 Widget (heuristic)"
6843 ],
6844 "heuristic-reached files are listed after every precise one, never ranked among them"
6845 );
6846 assert!(
6847 crate::render::render_impact_compact("Core/Widget.cs", &model)
6848 .contains("Consumers/Guessed.cs via=1h"),
6849 "compact reports the guess count, never `via=0`"
6850 );
6851
6852 let defs = stage4_impact_defs();
6856 let edges = vec![precise];
6857 let root = stage4_root(&defs, &edges, "stage4-impact-without");
6858 let g = make_graph(defs, edges);
6859 let index = load_graph_index(&g, &root);
6860 let model = match build_impact_model(
6861 &index,
6862 "Core/Widget.cs",
6863 DEFAULT_HOPS,
6864 DEFAULT_CAP,
6865 true,
6866 DEFAULT_IFACE_MAX_FANIN,
6867 DEFAULT_HUB_MAX_INDEGREE,
6868 ) {
6869 ImpactResult::Resolved(m) => m,
6870 other => panic!("expected a resolved model, got {other:?}"),
6871 };
6872 let out = crate::render::render_impact_text("Core/Widget.cs", &model);
6873 assert!(
6874 out.contains("affected files: 1 shown: 1 dropped: 0"),
6875 "{out}"
6876 );
6877 assert!(!out.contains("heuristic"), "{out}");
6878 assert!(
6879 !crate::render::render_impact_compact("Core/Widget.cs", &model).contains("heuristic")
6880 );
6881 }
6882
6883 #[test]
6884 fn stage4_cli_impact_stops_at_a_heuristic_edge_instead_of_walking_through_it_to_a_second_hop() {
6885 let impact_out = |heuristic: bool, label: &str| {
6890 let defs = vec![
6891 widget_def(),
6892 def(
6893 "App.Mid.Middle",
6894 "Middle",
6895 "App.Mid",
6896 "class",
6897 "Mid/Middle.cs",
6898 3,
6899 ),
6900 def("App.Far.Far", "Far", "App.Far", "class", "Far/Far.cs", 3),
6901 ];
6902 let first = if heuristic {
6903 heuristic_uses_member("Mid/Middle.cs", 8, "App.Core.Widget", "Core/Widget.cs")
6904 } else {
6905 uses_member("Mid/Middle.cs", 8, "App.Core.Widget", "Core/Widget.cs")
6906 };
6907 let edges = vec![
6908 first,
6909 uses_type("Far/Far.cs", 4, "App.Mid.Middle", "Mid/Middle.cs"),
6910 ];
6911 let root = stage4_root(&defs, &edges, label);
6912 let g = make_graph(defs, edges);
6913 let index = load_graph_index(&g, &root);
6914 let model = match build_impact_model(
6915 &index,
6916 "Core/Widget.cs",
6917 2,
6918 DEFAULT_CAP,
6919 true,
6920 DEFAULT_IFACE_MAX_FANIN,
6921 DEFAULT_HUB_MAX_INDEGREE,
6922 ) {
6923 ImpactResult::Resolved(m) => m,
6924 other => panic!("expected a resolved model, got {other:?}"),
6925 };
6926 crate::render::render_impact_text("Core/Widget.cs", &model)
6927 };
6928
6929 let control = impact_out(false, "stage4-walk-control");
6930 assert!(
6931 control.contains("Far/Far.cs"),
6932 "control: with a precise first link the walk reaches the second hop\n{control}"
6933 );
6934 assert!(control.contains("affected files: 2 "), "{control}");
6935
6936 let guessed = impact_out(true, "stage4-walk-guess");
6937 assert!(
6938 guessed.contains("Mid/Middle.cs 1 1 Widget (heuristic)"),
6939 "the guessed file itself is still reported\n{guessed}"
6940 );
6941 assert!(
6942 !guessed.contains("Far/Far.cs"),
6943 "a guess may reach a file and must never become the premise of the next hop -- compounding guesses is how blast radius turns into fiction\n{guessed}"
6944 );
6945 assert!(
6946 guessed.contains("affected files: 0 (+1 heuristic) shown: 1 dropped: 0"),
6947 "{guessed}"
6948 );
6949 }
6950
6951 #[test]
6955 fn stage4_heuristic_edges_never_enter_the_precise_adjacency() {
6956 let defs = vec![widget_def()];
6957 let edges = vec![heuristic_uses_type(
6958 "Consumers/Guess.cs",
6959 7,
6960 "App.Core.Widget",
6961 "Core/Widget.cs",
6962 )];
6963 let root = stage4_root(&defs, &edges, "stage4-adjacency");
6964 let g = make_graph(defs, edges);
6965 let index = load_graph_index(&g, &root);
6966 assert!(
6967 index.inbound.get("App.Core.Widget").is_none(),
6968 "the precise inbound table never sees a tagged edge"
6969 );
6970 assert_eq!(
6971 index
6972 .heuristic_inbound
6973 .get("App.Core.Widget")
6974 .map(|e| e.uses_type.len()),
6975 Some(1),
6976 "and the heuristic one holds it, keyed by the same def id"
6977 );
6978 assert!(index.outbound_by_file.get("Consumers/Guess.cs").is_none());
6979 assert_eq!(
6980 index
6981 .heuristic_outbound_by_file
6982 .get("Consumers/Guess.cs")
6983 .map(|e| e.uses_type.len()),
6984 Some(1)
6985 );
6986 }
6987
6988 fn test_def(
6994 id: &str,
6995 name: &str,
6996 file: &str,
6997 line: usize,
6998 test_methods: &[&str],
6999 ) -> graph::Def {
7000 graph::Def {
7001 test_methods: test_methods.iter().map(|s| s.to_string()).collect(),
7002 ..def(id, name, "App.Orders.Tests", "class", file, line)
7003 }
7004 }
7005
7006 const TESTS_MANIFEST_FILES: &[&str] = &[
7007 "src/OrderService.cs",
7008 "src/Untested.cs",
7009 "tests/OrderServiceTests.cs",
7010 "tests/Fakes.cs",
7011 "tests/Partial.cs",
7012 "tests/Partial.Extra.cs",
7013 ];
7014
7015 fn tests_fixture_graph() -> graph::Graph {
7019 let mut partial = test_def(
7020 "App.Orders.Tests.PartialTests",
7021 "PartialTests",
7022 "tests/Partial.cs",
7023 3,
7024 &["Scales"],
7025 );
7026 partial.also_in = vec![graph::AlsoIn {
7027 file: "tests/Partial.Extra.cs".into(),
7028 line: 3,
7029 }];
7030 make_graph(
7031 vec![
7032 def(
7033 "App.Orders.OrderService",
7034 "OrderService",
7035 "App.Orders",
7036 "class",
7037 "src/OrderService.cs",
7038 3,
7039 ),
7040 def(
7041 "App.Orders.Untested",
7042 "Untested",
7043 "App.Orders",
7044 "class",
7045 "src/Untested.cs",
7046 3,
7047 ),
7048 test_def(
7049 "App.Orders.Tests.OrderServiceTests",
7050 "OrderServiceTests",
7051 "tests/OrderServiceTests.cs",
7052 5,
7053 &["Totals"],
7054 ),
7055 def(
7056 "App.Orders.Tests.Fakes",
7057 "Fakes",
7058 "App.Orders.Tests",
7059 "class",
7060 "tests/Fakes.cs",
7061 3,
7062 ),
7063 partial,
7064 ],
7065 vec![
7066 uses_type(
7067 "tests/OrderServiceTests.cs",
7068 11,
7069 "App.Orders.OrderService",
7070 "src/OrderService.cs",
7071 ),
7072 uses_type(
7073 "tests/OrderServiceTests.cs",
7074 10,
7075 "App.Orders.OrderService",
7076 "src/OrderService.cs",
7077 ),
7078 uses_type(
7079 "tests/OrderServiceTests.cs",
7080 10,
7081 "App.Orders.OrderService",
7082 "src/OrderService.cs",
7083 ),
7084 uses_type(
7085 "tests/Fakes.cs",
7086 7,
7087 "App.Orders.OrderService",
7088 "src/OrderService.cs",
7089 ),
7090 heuristic_uses_member(
7091 "tests/Partial.Extra.cs",
7092 9,
7093 "App.Orders.OrderService",
7094 "src/OrderService.cs",
7095 ),
7096 ],
7097 )
7098 }
7099
7100 fn tests_fixture_root() -> PathBuf {
7101 let root = temp_repo_root("tests-model");
7102 write_manifest_fixture(&root, TESTS_MANIFEST_FILES);
7103 root
7104 }
7105
7106 fn resolved_tests(model: TestsResult) -> TestsModel {
7107 match model {
7108 TestsResult::Resolved(m) => m,
7109 other => panic!("expected a resolved tests model, got {other:?}"),
7110 }
7111 }
7112
7113 #[test]
7114 fn test_defs_by_file_registers_every_declaring_site_and_never_a_file_without_a_test_def() {
7115 let g = tests_fixture_graph();
7116 let index = load_graph_index(&g, &tests_fixture_root());
7117 assert!(index
7118 .test_defs_by_file
7119 .contains_key("tests/OrderServiceTests.cs"));
7120 assert!(
7121 index
7122 .test_defs_by_file
7123 .contains_key("tests/Partial.Extra.cs"),
7124 "a partial test class registers its second declaring file too"
7125 );
7126 assert!(
7127 !index.test_defs_by_file.contains_key("tests/Fakes.cs"),
7128 "a file is a test file only because of the attribute"
7129 );
7130 assert!(!index.test_defs_by_file.contains_key("src/OrderService.cs"));
7131 }
7132
7133 #[test]
7134 fn build_tests_model_names_the_test_file_its_test_defs_and_every_referencing_line() {
7135 let g = tests_fixture_graph();
7136 let index = load_graph_index(&g, &tests_fixture_root());
7137 let m = resolved_tests(build_tests_model(&index, "OrderService"));
7138
7139 assert_eq!(m.symbol, "App.Orders.OrderService");
7140 assert_eq!(m.def_files, vec!["src/OrderService.cs".to_string()]);
7141 assert_eq!(
7142 m.test_file_count, 1,
7143 "the non-test neighbour is not a test file"
7144 );
7145 assert_eq!(
7146 m.ref_count, 3,
7147 "lines keep duplicates -- refCount is the reference count, not the distinct-line count"
7148 );
7149 assert_eq!(m.rows.len(), 2, "one precise row, then the heuristic one");
7150
7151 let precise = &m.rows[0];
7152 assert_eq!(precise.file, "tests/OrderServiceTests.cs");
7153 assert_eq!(
7154 precise.test_defs,
7155 vec!["App.Orders.Tests.OrderServiceTests".to_string()]
7156 );
7157 assert_eq!(
7158 precise.lines,
7159 vec![10, 10, 11],
7160 "ascending, duplicates kept"
7161 );
7162 assert_eq!(precise.ref_count, 3);
7163 assert!(!precise.heuristic);
7164 }
7165
7166 #[test]
7167 fn build_tests_model_puts_heuristic_rows_after_every_precise_one_and_counts_them_separately() {
7168 let g = tests_fixture_graph();
7169 let index = load_graph_index(&g, &tests_fixture_root());
7170 let m = resolved_tests(build_tests_model(&index, "OrderService"));
7171
7172 let guessed = &m.rows[1];
7173 assert!(
7174 guessed.heuristic,
7175 "a guess never sits inside the list of facts"
7176 );
7177 assert_eq!(guessed.file, "tests/Partial.Extra.cs");
7178 assert_eq!(
7179 guessed.test_defs,
7180 vec!["App.Orders.Tests.PartialTests".to_string()]
7181 );
7182 assert_eq!(guessed.lines, vec![9]);
7183 assert_eq!(m.heuristic_file_count, 1);
7184 assert_eq!(m.heuristic_ref_count, 1);
7185 assert_eq!(m.test_file_count, 1, "files= and refs= stay PRECISE-only");
7186 }
7187
7188 #[test]
7189 fn build_tests_model_on_a_symbol_no_test_references_resolves_with_no_rows() {
7190 let g = tests_fixture_graph();
7191 let index = load_graph_index(&g, &tests_fixture_root());
7192 let m = resolved_tests(build_tests_model(&index, "Untested"));
7193 assert_eq!(m.symbol, "App.Orders.Untested");
7194 assert!(m.rows.is_empty());
7195 assert_eq!(
7196 (
7197 m.test_file_count,
7198 m.ref_count,
7199 m.heuristic_file_count,
7200 m.heuristic_ref_count
7201 ),
7202 (0, 0, 0, 0)
7203 );
7204 }
7205
7206 #[test]
7207 fn build_tests_model_uses_the_same_resolve_symbol_ladder_refs_does() {
7208 let g = tests_fixture_graph();
7209 let index = load_graph_index(&g, &tests_fixture_root());
7210 assert_eq!(
7211 build_tests_model(&index, "NoSuchSymbol"),
7212 TestsResult::NotFound
7213 );
7214 assert_eq!(
7215 resolved_tests(build_tests_model(&index, "orderservice")).symbol,
7216 "App.Orders.OrderService",
7217 "case-insensitive unique name is the ladder's last rung, same as refs"
7218 );
7219
7220 let ambiguous_graph = base_fixture_graph();
7221 let ambiguous_index = load_graph_index(&ambiguous_graph, &base_fixture_root());
7222 assert_eq!(
7223 build_tests_model(&ambiguous_index, "Config"),
7224 TestsResult::Ambiguous(vec![
7225 "App.One.Config".to_string(),
7226 "App.Two.Config".to_string()
7227 ])
7228 );
7229 }
7230
7231 #[test]
7232 fn build_impact_model_counts_precisely_affected_test_files_only() {
7233 let g = tests_fixture_graph();
7234 let index = load_graph_index(&g, &tests_fixture_root());
7235
7236 let ImpactResult::Resolved(covered) = build_impact_model(
7237 &index,
7238 "src/OrderService.cs",
7239 2,
7240 DEFAULT_CAP,
7241 true,
7242 DEFAULT_IFACE_MAX_FANIN,
7243 DEFAULT_HUB_MAX_INDEGREE,
7244 ) else {
7245 panic!("file seed resolves");
7246 };
7247 assert_eq!(
7248 covered.tests_affected, 1,
7249 "the guessed test file is not coverage; the non-test neighbour is not a test file"
7250 );
7251
7252 let ImpactResult::Resolved(untouched) = build_impact_model(
7253 &index,
7254 "src/Untested.cs",
7255 2,
7256 DEFAULT_CAP,
7257 true,
7258 DEFAULT_IFACE_MAX_FANIN,
7259 DEFAULT_HUB_MAX_INDEGREE,
7260 ) else {
7261 panic!("file seed resolves");
7262 };
7263 assert_eq!(
7264 untouched.tests_affected, 0,
7265 "zero is the interesting answer -- a blast radius reaching no test file at all"
7266 );
7267 }
7268 fn name_row(name: &str, kind: &str, file: &str, line: usize, owner: &str) -> graph::GraphName {
7271 graph::GraphName {
7272 name: name.into(),
7273 kind: kind.into(),
7274 file: file.into(),
7275 line,
7276 owner: owner.into(),
7277 }
7278 }
7279
7280 fn member_fixture() -> (graph::Graph, PathBuf) {
7286 let mut g = make_graph(
7287 vec![
7288 def(
7289 "App.Books.Ledger",
7290 "Ledger",
7291 "App.Books",
7292 "class",
7293 "Books/Ledger.cs",
7294 3,
7295 ),
7296 def(
7297 "App.Books.Journal",
7298 "Journal",
7299 "App.Books",
7300 "class",
7301 "Books/Journal.cs",
7302 3,
7303 ),
7304 def(
7305 "App.Books.Consumer",
7306 "Consumer",
7307 "App.Books",
7308 "class",
7309 "Books/Consumer.cs",
7310 1,
7311 ),
7312 ],
7313 vec![
7314 uses_member(
7315 "Books/Consumer.cs",
7316 1,
7317 "App.Books.Ledger",
7318 "Books/Ledger.cs",
7319 ),
7320 uses_member(
7321 "Books/Consumer.cs",
7322 2,
7323 "App.Books.Ledger",
7324 "Books/Ledger.cs",
7325 ),
7326 uses_member(
7327 "Books/Consumer.cs",
7328 3,
7329 "App.Books.Ledger",
7330 "Books/Ledger.cs",
7331 ),
7332 uses_member(
7333 "Books/Consumer.cs",
7334 4,
7335 "App.Books.Journal",
7336 "Books/Journal.cs",
7337 ),
7338 ],
7339 );
7340 g.names = vec![
7341 name_row("Ledger", "class", "Books/Ledger.cs", 3, ""),
7342 name_row("Post", "method", "Books/Ledger.cs", 5, "App.Books.Ledger"),
7343 name_row("PostEx", "method", "Books/Ledger.cs", 7, "App.Books.Ledger"),
7344 name_row(
7345 "Reconcile",
7346 "method",
7347 "Books/Ledger.cs",
7348 9,
7349 "App.Books.Ledger",
7350 ),
7351 name_row(
7352 "Approve",
7353 "method",
7354 "Books/Ledger.cs",
7355 11,
7356 "App.Books.Ledger",
7357 ),
7358 name_row("Journal", "class", "Books/Journal.cs", 3, ""),
7359 name_row(
7360 "Approve",
7361 "method",
7362 "Books/Journal.cs",
7363 5,
7364 "App.Books.Journal",
7365 ),
7366 ];
7367 let root = temp_repo_root("bare-member");
7368 write_manifest_fixture(
7369 &root,
7370 &["Books/Ledger.cs", "Books/Journal.cs", "Books/Consumer.cs"],
7371 );
7372 fs::create_dir_all(root.join("Books")).expect("fixture dir");
7373 fs::write(
7374 root.join("Books/Consumer.cs"),
7375 "Ledger.Post(1);\nLedger.PostEx(2);\nLedger.Approve(3);\nJournal.Approve(4);\n",
7376 )
7377 .expect("fixture file");
7378 (g, root)
7379 }
7380
7381 fn member_models(index: &GraphIndex, query: &str, inbound_cap: usize) -> Vec<RefsModel> {
7382 match build_refs_model(
7383 index,
7384 query,
7385 false,
7386 DEFAULT_CAP,
7387 inbound_cap,
7388 OUTBOUND_CAP,
7389 false,
7390 ) {
7391 RefsResult::Members(models) => models,
7392 other => panic!("expected Members, got {other:?}"),
7393 }
7394 }
7395
7396 #[test]
7397 fn line_has_token_matches_whole_tokens_only() {
7398 assert!(line_has_token("Ledger.Post(1);", "Post"));
7399 assert!(line_has_token("Post", "Post"));
7400 assert!(!line_has_token("Ledger.PostEx(2);", "Post"));
7401 assert!(!line_has_token("RePost(2);", "Post"));
7402 assert!(!line_has_token("Post_x();", "Post"));
7403 assert!(!line_has_token("x1Post();", "Post"));
7404 assert!(line_has_token("\u{2026}Post\u{2026}", "Post"));
7407 assert!(!line_has_token("", "Post"));
7408 assert!(!line_has_token("Post", ""));
7409 }
7410
7411 #[test]
7412 fn build_refs_model_bare_member_keeps_only_the_edges_whose_line_names_it() {
7413 let (g, root) = member_fixture();
7414 let index = load_graph_index(&g, &root);
7415 let models = member_models(&index, "PostEx", INBOUND_CAP);
7416
7417 assert_eq!(models.len(), 1);
7418 let m = &models[0];
7419 assert_eq!(m.id, "App.Books.Ledger.PostEx");
7420 assert_eq!(m.kind, "member");
7421 assert_eq!(
7422 m.sites,
7423 vec![DefSite {
7424 file: "Books/Ledger.cs".into(),
7425 line: 7
7426 }]
7427 );
7428 assert_eq!(
7429 m.inbound.uses_member.total, 1,
7430 "the type has three inbound member edges; one names PostEx"
7431 );
7432 assert_eq!(
7433 m.inbound.uses_member.rows,
7434 vec![InboundRow {
7435 file: "Books/Consumer.cs".into(),
7436 line: 2,
7437 heuristic: false,
7438 source: "Ledger.PostEx(2);".into()
7439 }]
7440 );
7441 assert_eq!(m.inbound.inherits.total, 0);
7442 assert!(
7443 m.outbound.is_none(),
7444 "a member answer never carries the outbound tables"
7445 );
7446 }
7447
7448 #[test]
7449 fn build_refs_model_bare_member_refuses_a_longer_identifier_that_starts_with_the_query() {
7450 let (g, root) = member_fixture();
7451 let index = load_graph_index(&g, &root);
7452 let models = member_models(&index, "Post", INBOUND_CAP);
7453 let ledger = models
7454 .iter()
7455 .find(|m| m.id == "App.Books.Ledger.Post")
7456 .expect("Ledger declares Post");
7457
7458 assert_eq!(
7459 ledger
7460 .inbound
7461 .uses_member
7462 .rows
7463 .iter()
7464 .map(|r| r.line)
7465 .collect::<Vec<_>>(),
7466 vec![1],
7467 "line 2 is `Ledger.PostEx(2);` -- a substring hit, not a token hit"
7468 );
7469 }
7470
7471 #[test]
7477 fn build_refs_model_bare_member_verified_on_several_types_answers_ambiguous_not_members() {
7478 let (g, root) = member_fixture();
7479 let index = load_graph_index(&g, &root);
7480 let model = build_refs_model(
7481 &index,
7482 "Approve",
7483 false,
7484 DEFAULT_CAP,
7485 INBOUND_CAP,
7486 OUTBOUND_CAP,
7487 false,
7488 );
7489
7490 assert_eq!(
7491 model,
7492 RefsResult::Ambiguous(vec![
7493 "App.Books.Ledger".to_string(),
7494 "App.Books.Journal".to_string()
7495 ])
7496 );
7497 }
7498
7499 #[test]
7500 fn build_refs_model_bare_member_ambiguous_across_types_answers_the_same_regardless_of_inbound_cap(
7501 ) {
7502 let (g, root) = member_fixture();
7503 let index = load_graph_index(&g, &root);
7504 let model = build_refs_model(
7505 &index,
7506 "Approve",
7507 false,
7508 DEFAULT_CAP,
7509 1,
7510 OUTBOUND_CAP,
7511 false,
7512 );
7513
7514 assert_eq!(
7515 model,
7516 RefsResult::Ambiguous(vec![
7517 "App.Books.Ledger".to_string(),
7518 "App.Books.Journal".to_string()
7519 ])
7520 );
7521 }
7522
7523 #[test]
7524 fn build_refs_model_bare_member_with_no_verified_edge_stays_not_found() {
7525 let (g, root) = member_fixture();
7526 let index = load_graph_index(&g, &root);
7527
7528 assert_eq!(
7529 build_refs_model(
7530 &index,
7531 "Reconcile",
7532 false,
7533 DEFAULT_CAP,
7534 INBOUND_CAP,
7535 OUTBOUND_CAP,
7536 false
7537 ),
7538 RefsResult::NotFound
7539 );
7540 assert_eq!(
7541 build_refs_model(
7542 &index,
7543 "NoSuchMemberAnywhere",
7544 false,
7545 DEFAULT_CAP,
7546 INBOUND_CAP,
7547 OUTBOUND_CAP,
7548 false
7549 ),
7550 RefsResult::NotFound
7551 );
7552 }
7553
7554 #[test]
7555 fn build_refs_model_prefers_a_type_over_a_member_of_the_same_name() {
7556 let (g, root) = member_fixture();
7557 let index = load_graph_index(&g, &root);
7558
7559 let RefsResult::Resolved(model) = build_refs_model(
7560 &index,
7561 "Ledger",
7562 false,
7563 DEFAULT_CAP,
7564 INBOUND_CAP,
7565 OUTBOUND_CAP,
7566 false,
7567 ) else {
7568 panic!("Ledger is a type and must resolve as one");
7569 };
7570 assert_eq!(model.id, "App.Books.Ledger");
7571 }
7572
7573 #[test]
7574 fn read_excludes_references_inside_the_target_declaration_span() {
7575 let root = temp_repo_root("read-self-inbound");
7576 fs::create_dir_all(root.join("Core")).unwrap();
7577 fs::create_dir_all(root.join("Consumers")).unwrap();
7578 fs::write(
7579 root.join("Core/Widget.cs"),
7580 "namespace App;\npublic class Widget\n{\n Widget Again() => new Widget();\n}\n",
7581 )
7582 .unwrap();
7583 fs::write(
7584 root.join("Consumers/Reader.cs"),
7585 "namespace App;\npublic class Reader { Widget Value; }\n",
7586 )
7587 .unwrap();
7588 write_manifest_fixture(&root, &["Core/Widget.cs", "Consumers/Reader.cs"]);
7589 let mut target = def("App.Widget", "Widget", "App", "class", "Core/Widget.cs", 2);
7590 target.end_line = 5;
7591 let graph = make_graph(
7592 vec![target],
7593 vec![
7594 uses_type("Core/Widget.cs", 4, "App.Widget", "Core/Widget.cs"),
7595 uses_type("Consumers/Reader.cs", 2, "App.Widget", "Core/Widget.cs"),
7596 ],
7597 );
7598 let index = load_graph_index(&graph, &root);
7599 let ReadResult::Resolved(model) = build_read_model(&index, "Widget") else {
7600 panic!("Widget must resolve")
7601 };
7602 assert_eq!(model.refs.inbound.uses_type.total, 1);
7603 assert_eq!(
7604 model.refs.inbound.uses_type.rows[0].file,
7605 "Consumers/Reader.cs"
7606 );
7607 }
7608
7609 fn graph_name(name: &str, kind: &str, file: &str, line: usize) -> graph::GraphName {
7612 graph::GraphName {
7613 name: name.to_string(),
7614 kind: kind.to_string(),
7615 file: file.to_string(),
7616 line,
7617 owner: String::new(),
7618 }
7619 }
7620
7621 #[test]
7622 fn first_decl_line_by_file_keeps_the_minimum_line_per_file_across_the_whole_name_index_not_just_the_last_one_seen(
7623 ) {
7624 let mut graph = make_graph(vec![], vec![]);
7625 graph.names = vec![
7626 graph_name("Widget", "class", "a.cs", 5),
7627 graph_name("Render", "method", "a.cs", 12),
7628 graph_name("Widget", "class", "a.cs", 1),
7629 graph_name("Other", "class", "b.cs", 8),
7630 ];
7631 let mut rows: Vec<(String, usize)> = first_decl_line_by_file(&graph).into_iter().collect();
7632 rows.sort();
7633 assert_eq!(rows, vec![("a.cs".to_string(), 1), ("b.cs".to_string(), 8)]);
7634 }
7635
7636 #[test]
7637 fn first_decl_line_by_file_on_a_graph_with_no_names_index_is_empty() {
7638 assert!(first_decl_line_by_file(&make_graph(vec![], vec![])).is_empty());
7639 }
7640}