1use crate::error::Result;
2use crate::types::{AnalysisResult, Edge, Node, NodeId};
3use std::collections::{HashMap, HashSet};
4use std::process::Command;
5
6pub struct Analyzer;
7
8impl Analyzer {
9 pub fn god_nodes(&self, nodes: &[Node], edges: &[Edge], top_n: usize) -> Vec<Node> {
10 let mut degree: HashMap<&str, usize> = HashMap::new();
11 for edge in edges {
12 *degree.entry(edge.source.as_str()).or_insert(0) += 1;
13 *degree.entry(edge.target.as_str()).or_insert(0) += 1;
14 }
15
16 let mut candidates: Vec<&Node> = nodes
17 .iter()
18 .filter(|n| {
19 let deg = *degree.get(n.id.as_str()).unwrap_or(&0);
20 !n.label.ends_with(".py")
21 && !n.label.ends_with(".js")
22 && !n.label.ends_with(".ts")
23 && !n.label.ends_with(".rs")
24 && !n.label.ends_with(".go")
25 && !n.label.ends_with(".java")
26 && !n.label.ends_with(".c")
27 && !n.label.ends_with(".cpp")
28 && !n.label.ends_with(".h")
29 && !n.label.contains("().")
30 && !(n.label.starts_with(".") && n.label.ends_with("()"))
31 && !(n.label.ends_with("()") && deg <= 1)
32 })
33 .collect();
34
35 candidates.sort_by(|a, b| {
36 let deg_a = degree.get(a.id.as_str()).unwrap_or(&0);
37 let deg_b = degree.get(b.id.as_str()).unwrap_or(&0);
38 deg_b.cmp(deg_a)
39 });
40
41 candidates.truncate(top_n);
42 candidates.into_iter().cloned().collect()
43 }
44
45 pub fn surprising_connections(
46 &self,
47 edges: &[Edge],
48 cross_language_suppression: bool,
49 ) -> Vec<Edge> {
50 if cross_language_suppression {
51 edges
52 .iter()
53 .filter(|e| {
54 e.confidence == "INFERRED"
55 && e.source_file.as_deref().is_none_or(|sf| {
56 sf.ends_with(".py")
57 || sf.ends_with(".js")
58 || sf.ends_with(".ts")
59 || sf.ends_with(".rs")
60 || sf.ends_with(".go")
61 })
62 })
63 .cloned()
64 .collect()
65 } else {
66 edges
67 .iter()
68 .filter(|e| e.confidence == "INFERRED")
69 .cloned()
70 .collect()
71 }
72 }
73
74 pub fn suggest_questions(&self, nodes: &[Node], edges: &[Edge]) -> Vec<String> {
75 let mut questions = Vec::new();
76
77 questions.push("What are the core modules and how do they relate?".to_string());
78 questions.push("Which components have the most dependencies?".to_string());
79
80 let has_cycles = self.detect_cycles(nodes, edges);
81 if has_cycles {
82 questions.push("Are there any circular dependencies between modules?".to_string());
83 }
84
85 questions.push("How does data flow through the system?".to_string());
86
87 let lang_count = self.count_languages(nodes);
88 if lang_count.len() > 1 {
89 questions.push(format!(
90 "How do the {} different languages interact?",
91 lang_count.len()
92 ));
93 }
94
95 questions
96 }
97
98 pub fn cohesion_score(
99 &self,
100 _nodes: &[Node],
101 edges: &[Edge],
102 community_nodes: &[NodeId],
103 ) -> f64 {
104 if community_nodes.len() <= 1 {
105 return 1.0;
106 }
107
108 let node_set: HashSet<&str> = community_nodes.iter().map(|s| s.as_str()).collect();
109 let internal_edges = edges
110 .iter()
111 .filter(|e| {
112 node_set.contains(e.source.as_str()) && node_set.contains(e.target.as_str())
113 })
114 .count();
115 let max_possible = community_nodes.len() * (community_nodes.len() - 1) / 2;
116
117 if max_possible == 0 {
118 return 1.0;
119 }
120
121 internal_edges as f64 / max_possible as f64
122 }
123
124 pub fn pagerank(&self, edges: &[Edge], damping: f64, max_iter: usize) -> HashMap<String, f64> {
125 let mut node_degree: HashMap<&str, f64> = HashMap::new();
126 let mut outlinks: HashMap<&str, Vec<&str>> = HashMap::new();
127 let mut all_nodes: HashSet<&str> = HashSet::new();
128
129 for edge in edges {
130 all_nodes.insert(edge.source.as_str());
131 all_nodes.insert(edge.target.as_str());
132 *node_degree.entry(edge.source.as_str()).or_insert(0.0) += 1.0;
133 outlinks
134 .entry(edge.source.as_str())
135 .or_default()
136 .push(edge.target.as_str());
137 }
138
139 let n = all_nodes.len() as f64;
140 if n == 0.0 {
141 return HashMap::new();
142 }
143
144 let mut ranks: HashMap<String, f64> = all_nodes
145 .iter()
146 .map(|&id| (id.to_string(), 1.0 / n))
147 .collect();
148
149 for _iter in 0..max_iter {
150 let mut new_ranks: HashMap<String, f64> = HashMap::new();
151 let dangling_sum: f64 = all_nodes
152 .iter()
153 .filter(|id| outlinks.get(*id).is_none_or(|v| v.is_empty()))
154 .map(|id| ranks[*id])
155 .sum();
156
157 for &node in &all_nodes {
158 let mut score = (1.0 - damping) / n;
159 score += damping * dangling_sum / n;
160
161 for (&src, targets) in &outlinks {
162 if targets.contains(&node) {
163 score +=
164 damping * ranks.get(src).copied().unwrap_or(0.0) / targets.len() as f64;
165 }
166 }
167
168 new_ranks.insert(node.to_string(), score);
169 }
170
171 ranks = new_ranks;
172 }
173
174 ranks
175 }
176
177 pub fn graph_diff(&self, old: &[Edge], new: &[Edge]) -> (Vec<Edge>, Vec<Edge>) {
178 let old_set: HashSet<(&str, &str, &str)> = old
179 .iter()
180 .map(|e| (e.source.as_str(), e.target.as_str(), e.relation.as_str()))
181 .collect();
182 let new_set: HashSet<(&str, &str, &str)> = new
183 .iter()
184 .map(|e| (e.source.as_str(), e.target.as_str(), e.relation.as_str()))
185 .collect();
186
187 let added: Vec<Edge> = new
188 .iter()
189 .filter(|e| {
190 !old_set.contains(&(e.source.as_str(), e.target.as_str(), e.relation.as_str()))
191 })
192 .cloned()
193 .collect();
194
195 let removed: Vec<Edge> = old
196 .iter()
197 .filter(|e| {
198 !new_set.contains(&(e.source.as_str(), e.target.as_str(), e.relation.as_str()))
199 })
200 .cloned()
201 .collect();
202
203 (added, removed)
204 }
205
206 fn detect_cycles(&self, _nodes: &[Node], edges: &[Edge]) -> bool {
207 let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
208 for edge in edges {
209 graph
210 .entry(edge.source.as_str())
211 .or_default()
212 .push(edge.target.as_str());
213 }
214
215 let mut visited: HashSet<&str> = HashSet::new();
216 let mut stack: HashSet<&str> = HashSet::new();
217
218 fn dfs<'a>(
219 node: &'a str,
220 graph: &HashMap<&'a str, Vec<&'a str>>,
221 visited: &mut HashSet<&'a str>,
222 stack: &mut HashSet<&'a str>,
223 ) -> bool {
224 if stack.contains(node) {
225 return true;
226 }
227 if visited.contains(node) {
228 return false;
229 }
230 visited.insert(node);
231 stack.insert(node);
232 if let Some(neighbors) = graph.get(node) {
233 for &next in neighbors {
234 if dfs(next, graph, visited, stack) {
235 return true;
236 }
237 }
238 }
239 stack.remove(node);
240 false
241 }
242
243 let all_nodes: Vec<&str> = graph.keys().copied().collect();
244 for node in all_nodes {
245 if dfs(node, &graph, &mut visited, &mut stack) {
246 return true;
247 }
248 }
249 false
250 }
251
252 fn count_languages(&self, nodes: &[Node]) -> HashMap<String, usize> {
253 let mut counts: HashMap<String, usize> = HashMap::new();
254 for node in nodes {
255 let lang = language_from_file(&node.source_file);
256 *counts.entry(lang).or_insert(0) += 1;
257 }
258 counts
259 }
260
261 pub fn tarjan_scc(&self, edges: &[Edge]) -> Vec<Vec<String>> {
262 let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
263 for edge in edges {
264 graph
265 .entry(edge.source.as_str())
266 .or_default()
267 .push(edge.target.as_str());
268 graph.entry(edge.target.as_str()).or_default();
269 }
270
271 let mut index_counter = 0usize;
272 let mut index: HashMap<&str, usize> = HashMap::new();
273 let mut lowlink: HashMap<&str, usize> = HashMap::new();
274 let mut stack: Vec<&str> = Vec::new();
275 let mut on_stack: HashSet<&str> = HashSet::new();
276 let mut sccs: Vec<Vec<String>> = Vec::new();
277
278 #[allow(clippy::too_many_arguments)]
279 fn strongconnect<'a>(
280 v: &'a str,
281 graph: &HashMap<&'a str, Vec<&'a str>>,
282 index_counter: &mut usize,
283 index: &mut HashMap<&'a str, usize>,
284 lowlink: &mut HashMap<&'a str, usize>,
285 stack: &mut Vec<&'a str>,
286 on_stack: &mut HashSet<&'a str>,
287 sccs: &mut Vec<Vec<String>>,
288 ) {
289 index.insert(v, *index_counter);
290 lowlink.insert(v, *index_counter);
291 *index_counter += 1;
292 stack.push(v);
293 on_stack.insert(v);
294
295 if let Some(neighbors) = graph.get(v) {
296 for &w in neighbors {
297 if !index.contains_key(w) {
298 strongconnect(
299 w,
300 graph,
301 index_counter,
302 index,
303 lowlink,
304 stack,
305 on_stack,
306 sccs,
307 );
308 let v_low = lowlink[v].min(lowlink[w]);
309 lowlink.insert(v, v_low);
310 } else if on_stack.contains(w) {
311 let v_low = lowlink[v].min(index[w]);
312 lowlink.insert(v, v_low);
313 }
314 }
315 }
316
317 if lowlink[v] == index[v] {
318 let mut scc = Vec::new();
319 loop {
320 let w = stack.pop().unwrap();
321 on_stack.remove(w);
322 scc.push(w.to_string());
323 if w == v {
324 break;
325 }
326 }
327 sccs.push(scc);
328 }
329 }
330
331 let all_nodes: Vec<&str> = graph.keys().copied().collect();
332 for node in all_nodes {
333 if !index.contains_key(node) {
334 strongconnect(
335 node,
336 &graph,
337 &mut index_counter,
338 &mut index,
339 &mut lowlink,
340 &mut stack,
341 &mut on_stack,
342 &mut sccs,
343 );
344 }
345 }
346
347 sccs
348 }
349
350 pub fn bridge_edges(&self, nodes: &[Node], edges: &[Edge]) -> Vec<Edge> {
351 if nodes.is_empty() || edges.is_empty() {
352 return vec![];
353 }
354
355 let id_to_idx: HashMap<&str, usize> = nodes
356 .iter()
357 .enumerate()
358 .map(|(i, n)| (n.id.as_str(), i))
359 .collect();
360 let n = nodes.len();
361 let mut adj: Vec<Vec<(usize, usize)>> = vec![vec![]; n];
362
363 for (ei, edge) in edges.iter().enumerate() {
364 if let (Some(&si), Some(&ti)) = (
365 id_to_idx.get(edge.source.as_str()),
366 id_to_idx.get(edge.target.as_str()),
367 ) {
368 adj[si].push((ti, ei));
369 adj[ti].push((si, ei));
370 }
371 }
372
373 let mut visited = vec![false; n];
374 let mut tin = vec![0usize; n];
375 let mut low = vec![0usize; n];
376 let mut timer = 0usize;
377 let mut is_bridge = vec![false; edges.len()];
378
379 #[allow(clippy::too_many_arguments)]
380 fn dfs(
381 v: usize,
382 p: Option<usize>,
383 adj: &[Vec<(usize, usize)>],
384 visited: &mut [bool],
385 tin: &mut [usize],
386 low: &mut [usize],
387 timer: &mut usize,
388 is_bridge: &mut [bool],
389 ) {
390 visited[v] = true;
391 tin[v] = *timer;
392 low[v] = *timer;
393 *timer += 1;
394
395 for &(to, ei) in &adj[v] {
396 if Some(to) == p {
397 continue;
398 }
399 if visited[to] {
400 low[v] = low[v].min(tin[to]);
401 } else {
402 dfs(to, Some(v), adj, visited, tin, low, timer, is_bridge);
403 low[v] = low[v].min(low[to]);
404 if low[to] > tin[v] {
405 is_bridge[ei] = true;
406 }
407 }
408 }
409 }
410
411 for i in 0..n {
412 if !visited[i] {
413 dfs(
414 i,
415 None,
416 &adj,
417 &mut visited,
418 &mut tin,
419 &mut low,
420 &mut timer,
421 &mut is_bridge,
422 );
423 }
424 }
425
426 edges
427 .iter()
428 .enumerate()
429 .filter(|(i, _)| is_bridge[*i])
430 .map(|(_, e)| e.clone())
431 .collect()
432 }
433
434 pub fn topological_sort(&self, edges: &[Edge]) -> Vec<String> {
435 let mut graph: HashMap<&str, Vec<&str>> = HashMap::new();
436 let mut in_degree: HashMap<&str, usize> = HashMap::new();
437
438 for edge in edges {
439 graph
440 .entry(edge.source.as_str())
441 .or_default()
442 .push(edge.target.as_str());
443 in_degree.entry(edge.source.as_str()).or_insert(0);
444 *in_degree.entry(edge.target.as_str()).or_insert(0) += 1;
445 }
446
447 let mut queue = std::collections::VecDeque::new();
448 for (&node, °) in &in_degree {
449 if deg == 0 {
450 queue.push_back(node);
451 }
452 }
453
454 let mut result = Vec::new();
455 while let Some(node) = queue.pop_front() {
456 result.push(node.to_string());
457 if let Some(neighbors) = graph.get(node) {
458 for &next in neighbors {
459 if let Some(d) = in_degree.get_mut(next) {
460 *d -= 1;
461 if *d == 0 {
462 queue.push_back(next);
463 }
464 }
465 }
466 }
467 }
468
469 result
470 }
471
472 pub fn analyze(&self, nodes: &[Node], edges: &[Edge]) -> Result<AnalysisResult> {
473 let god_nodes = self.god_nodes(nodes, edges, 10);
474 let surprising = self.surprising_connections(edges, false);
475 let questions = self.suggest_questions(nodes, edges);
476
477 Ok(AnalysisResult {
478 god_nodes,
479 surprising_connections: surprising,
480 suggested_questions: questions,
481 community_cohesion: vec![],
482 })
483 }
484
485 pub fn find_similar(&self, edges: &[Edge], node_id: &str, top_n: usize) -> Vec<(String, f64)> {
486 let pairs: Vec<(String, String)> = edges
487 .iter()
488 .map(|e| (e.source.clone(), e.target.clone()))
489 .collect();
490 let n2v = crate::embedding::Node2Vec::new(64, 1.0, 1.0);
491 let embeddings = n2v.train(&pairs);
492 n2v.find_similar(&embeddings, node_id, top_n)
493 }
494
495 pub fn compute_temporal_risk(&self, nodes: &mut [Node], edges: &[Edge]) -> Result<()> {
504 let is_git_repo = Command::new("git")
505 .arg("rev-parse")
506 .arg("--is-inside-work-tree")
507 .output()
508 .map(|output| output.status.success())
509 .unwrap_or(false);
510
511 if !is_git_repo {
512 return Ok(());
513 }
514
515 let mut churn_map: HashMap<String, usize> = HashMap::new();
516 for node in nodes.iter() {
517 if node.file_type != "file" {
518 continue;
519 }
520 let mut churn = 0usize;
521 if let Ok(output) = Command::new("git")
522 .arg("rev-list")
523 .arg("--count")
524 .arg("HEAD")
525 .arg("--")
526 .arg(&node.source_file)
527 .output()
528 {
529 if output.status.success() {
530 let stdout = String::from_utf8_lossy(&output.stdout);
531 if let Ok(count) = stdout.trim().parse::<usize>() {
532 churn = count;
533 }
534 }
535 }
536 churn_map.insert(node.id.clone(), churn);
537 }
538
539 self.apply_risk_scores(nodes, edges, &churn_map);
540 Ok(())
541 }
542
543 pub(crate) fn apply_risk_scores(
545 &self,
546 nodes: &mut [Node],
547 edges: &[Edge],
548 churn_map: &HashMap<String, usize>,
549 ) {
550 let mut degree: HashMap<String, usize> = HashMap::new();
551 for edge in edges {
552 *degree.entry(edge.source.clone()).or_insert(0) += 1;
553 *degree.entry(edge.target.clone()).or_insert(0) += 1;
554 }
555
556 let bridges = self.bridge_edges(nodes, edges);
557 let mut bridge_count: HashMap<String, usize> = HashMap::new();
558 for bridge in &bridges {
559 *bridge_count.entry(bridge.source.clone()).or_insert(0) += 1;
560 *bridge_count.entry(bridge.target.clone()).or_insert(0) += 1;
561 }
562
563 for node in nodes.iter_mut() {
564 if node.file_type != "file" {
565 continue;
566 }
567 let churn = churn_map.get(&node.id).copied().unwrap_or(0);
568 let deg = degree.get(&node.id).copied().unwrap_or(0);
569 let bridge_factor = 1.0 + bridge_count.get(&node.id).copied().unwrap_or(0) as f64;
570 let risk_score = churn as f64 * deg as f64 * bridge_factor;
571
572 node.metadata
573 .insert("risk_score".to_string(), risk_score.to_string());
574 node.rationale = Some(format!(
575 "Risk score: {risk_score:.2} (churn={churn}, degree={deg}, bridge_factor={bridge_factor:.1})"
576 ));
577 }
578 }
579}
580
581fn language_from_file(path: &str) -> String {
582 if path.ends_with(".py") {
583 "Python".to_string()
584 } else if path.ends_with(".js") || path.ends_with(".jsx") {
585 "JavaScript".to_string()
586 } else if path.ends_with(".ts") || path.ends_with(".tsx") {
587 "TypeScript".to_string()
588 } else if path.ends_with(".rs") {
589 "Rust".to_string()
590 } else if path.ends_with(".go") {
591 "Go".to_string()
592 } else if path.ends_with(".java") {
593 "Java".to_string()
594 } else if path.ends_with(".c") || path.ends_with(".h") {
595 "C".to_string()
596 } else if path.ends_with(".cpp") || path.ends_with(".hpp") {
597 "C++".to_string()
598 } else if path.ends_with(".rb") {
599 "Ruby".to_string()
600 } else if path.ends_with(".php") {
601 "PHP".to_string()
602 } else if path.ends_with(".swift") {
603 "Swift".to_string()
604 } else if path.ends_with(".kt") || path.ends_with(".kts") {
605 "Kotlin".to_string()
606 } else {
607 "Other".to_string()
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use std::collections::HashMap;
615
616 fn make_node(id: &str, label: &str, source_file: &str) -> Node {
617 Node {
618 id: id.to_string(),
619 label: label.to_string(),
620 file_type: "code".to_string(),
621 source_file: source_file.to_string(),
622 source_location: None,
623 community: None,
624 rationale: None,
625 docstring: None,
626 metadata: HashMap::new(),
627 }
628 }
629
630 fn make_node_id(id: &str) -> Node {
631 make_node(id, id, "test.py")
632 }
633
634 fn make_edge(src: &str, tgt: &str, relation: &str, confidence: &str) -> Edge {
635 Edge {
636 source: src.to_string(),
637 target: tgt.to_string(),
638 relation: relation.to_string(),
639 confidence: confidence.to_string(),
640 source_file: Some("test.py".to_string()),
641 weight: 1.0,
642 context: None,
643 }
644 }
645
646 fn make_edge_with_file(
647 src: &str,
648 tgt: &str,
649 relation: &str,
650 confidence: &str,
651 file: &str,
652 ) -> Edge {
653 Edge {
654 source: src.to_string(),
655 target: tgt.to_string(),
656 relation: relation.to_string(),
657 confidence: confidence.to_string(),
658 source_file: Some(file.to_string()),
659 weight: 1.0,
660 context: None,
661 }
662 }
663
664 #[test]
665 fn test_god_nodes_basic() {
666 let nodes = vec![make_node_id("a"), make_node_id("b"), make_node_id("c")];
667 let edges = vec![
668 make_edge("a", "b", "imports", "EXTRACTED"),
669 make_edge("a", "c", "imports", "EXTRACTED"),
670 ];
671 let analyzer = Analyzer;
672 let gods = analyzer.god_nodes(&nodes, &edges, 10);
673 assert_eq!(gods[0].id, "a");
674 }
675
676 #[test]
677 fn test_god_nodes_exclude_file() {
678 let nodes = vec![
679 make_node("a", "A", "test.py"),
680 make_node("main.py", "main.py", "test.py"),
681 ];
682 let edges = vec![make_edge("main.py", "a", "contains", "EXTRACTED")];
683 let analyzer = Analyzer;
684 let gods = analyzer.god_nodes(&nodes, &edges, 10);
685 assert_eq!(gods.len(), 1);
686 assert_eq!(gods[0].id, "a");
687 }
688
689 #[test]
690 fn test_god_nodes_exclude_method_stub() {
691 let nodes = vec![
693 make_node("hub", "HubClass", "app.py"),
694 make_node("stub_method", ".init()", "app.py"),
695 make_node("stub_fn", "setup()", "app.py"),
696 ];
697 let edges = vec![
698 make_edge("hub", "a", "calls", "EXTRACTED"),
699 make_edge("hub", "b", "calls", "EXTRACTED"),
700 make_edge("hub", "c", "calls", "EXTRACTED"),
701 make_edge("hub", "stub_fn", "contains", "EXTRACTED"),
703 ];
704 let analyzer = Analyzer;
705 let gods = analyzer.god_nodes(&nodes, &edges, 10);
706 assert!(
707 gods.iter().all(|n| n.id != "stub_method"),
708 "method stub should be excluded"
709 );
710 assert!(
711 gods.iter().all(|n| n.id != "stub_fn"),
712 "isolated fn stub should be excluded"
713 );
714 }
715
716 #[test]
717 fn test_surprising_connections_basic() {
718 let edges = vec![
719 make_edge("a", "b", "imports", "EXTRACTED"),
720 make_edge("c", "d", "calls", "INFERRED"),
721 ];
722 let analyzer = Analyzer;
723 let surprising = analyzer.surprising_connections(&edges, false);
724 assert_eq!(surprising.len(), 1);
725 assert_eq!(surprising[0].relation, "calls");
726 }
727
728 #[test]
729 fn test_surprising_cross_language_suppression() {
730 let edges = vec![
731 make_edge_with_file("a", "b", "imports", "INFERRED", "source.py"),
732 make_edge_with_file("c", "d", "imports", "INFERRED", "data.json"),
733 ];
734 let analyzer = Analyzer;
735 let filtered = analyzer.surprising_connections(&edges, true);
736 assert_eq!(filtered.len(), 1);
737 assert_eq!(filtered[0].source_file.as_deref(), Some("source.py"));
738 }
739
740 #[test]
741 fn test_suggest_questions_basic() {
742 let analyzer = Analyzer;
743 let questions = analyzer.suggest_questions(&[], &[]);
744 assert!(questions.len() >= 3);
745 }
746
747 #[test]
748 fn test_suggest_questions_with_cycle() {
749 let nodes = vec![make_node_id("a"), make_node_id("b")];
750 let edges = vec![
751 make_edge("a", "b", "imports", "EXTRACTED"),
752 make_edge("b", "a", "imports", "EXTRACTED"),
753 ];
754 let analyzer = Analyzer;
755 let questions = analyzer.suggest_questions(&nodes, &edges);
756 let has_cycle_q = questions.iter().any(|q| q.contains("circular"));
757 assert!(has_cycle_q);
758 }
759
760 #[test]
761 fn test_suggest_questions_multi_lang() {
762 let nodes = vec![
763 make_node("a", "A", "main.py"),
764 make_node("b", "B", "utils.ts"),
765 ];
766 let analyzer = Analyzer;
767 let questions = analyzer.suggest_questions(&nodes, &[]);
768 let has_lang_q = questions.iter().any(|q| q.contains("languages"));
769 assert!(has_lang_q);
770 }
771
772 #[test]
773 fn test_cohesion_complete() {
774 let nodes = ["a", "b", "c"];
775 let edges = vec![
776 make_edge("a", "b", "connects", "EXTRACTED"),
777 make_edge("a", "c", "connects", "EXTRACTED"),
778 make_edge("b", "c", "connects", "EXTRACTED"),
779 ];
780 let analyzer = Analyzer;
781 let score = analyzer.cohesion_score(
782 &[],
783 &edges,
784 &nodes.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
785 );
786 assert!((score - 1.0).abs() < 0.01);
787 }
788
789 #[test]
790 fn test_cohesion_empty() {
791 let analyzer = Analyzer;
792 let score = analyzer.cohesion_score(&[], &[], &[]);
793 assert!((score - 1.0).abs() < 0.01);
794 }
795
796 #[test]
797 fn test_cohesion_single() {
798 let analyzer = Analyzer;
799 let score = analyzer.cohesion_score(&[], &[], &["a".to_string()]);
800 assert!((score - 1.0).abs() < 0.01);
801 }
802
803 #[test]
804 fn test_pagerank_basic() {
805 let edges = vec![
806 make_edge("a", "b", "links", "EXTRACTED"),
807 make_edge("b", "c", "links", "EXTRACTED"),
808 make_edge("c", "a", "links", "EXTRACTED"),
809 ];
810 let analyzer = Analyzer;
811 let ranks = analyzer.pagerank(&edges, 0.85, 100);
812 assert!((ranks["a"] - ranks["b"]).abs() < 0.01);
813 assert!((ranks["b"] - ranks["c"]).abs() < 0.01);
814 }
815
816 #[test]
817 fn test_pagerank_skewed() {
818 let edges = vec![
819 make_edge("hub", "a", "links", "EXTRACTED"),
820 make_edge("hub", "b", "links", "EXTRACTED"),
821 make_edge("hub", "c", "links", "EXTRACTED"),
822 ];
823 let analyzer = Analyzer;
824 let ranks = analyzer.pagerank(&edges, 0.85, 100);
825 assert!(ranks["hub"] > 0.0);
826 assert!(ranks["a"] > 0.0);
827 }
828
829 #[test]
830 fn test_graph_diff_added() {
831 let old = vec![make_edge("a", "b", "imports", "EXTRACTED")];
832 let new = vec![
833 make_edge("a", "b", "imports", "EXTRACTED"),
834 make_edge("a", "c", "imports", "EXTRACTED"),
835 ];
836 let analyzer = Analyzer;
837 let (added, removed) = analyzer.graph_diff(&old, &new);
838 assert_eq!(added.len(), 1);
839 assert_eq!(added[0].target, "c");
840 assert!(removed.is_empty());
841 }
842
843 #[test]
844 fn test_graph_diff_removed() {
845 let old = vec![
846 make_edge("a", "b", "imports", "EXTRACTED"),
847 make_edge("a", "c", "imports", "EXTRACTED"),
848 ];
849 let new = vec![make_edge("a", "b", "imports", "EXTRACTED")];
850 let analyzer = Analyzer;
851 let (added, removed) = analyzer.graph_diff(&old, &new);
852 assert!(added.is_empty());
853 assert_eq!(removed.len(), 1);
854 assert_eq!(removed[0].target, "c");
855 }
856
857 #[test]
858 fn test_detect_cycles() {
859 let nodes = vec![make_node_id("a"), make_node_id("b")];
860 let edges = vec![
861 make_edge("a", "b", "imports", "EXTRACTED"),
862 make_edge("b", "a", "imports", "EXTRACTED"),
863 ];
864 let analyzer = Analyzer;
865 assert!(analyzer.detect_cycles(&nodes, &edges));
866 }
867
868 #[test]
869 fn test_detect_no_cycles() {
870 let nodes = vec![make_node_id("a"), make_node_id("b"), make_node_id("c")];
871 let edges = vec![
872 make_edge("a", "b", "imports", "EXTRACTED"),
873 make_edge("b", "c", "imports", "EXTRACTED"),
874 ];
875 let analyzer = Analyzer;
876 assert!(!analyzer.detect_cycles(&nodes, &edges));
877 }
878
879 #[test]
880 fn test_tarjan_scc_simple() {
881 let edges = vec![
882 make_edge("a", "b", "links", "EXTRACTED"),
883 make_edge("b", "c", "links", "EXTRACTED"),
884 make_edge("c", "a", "links", "EXTRACTED"),
885 ];
886 let analyzer = Analyzer;
887 let sccs = analyzer.tarjan_scc(&edges);
888 let large_scc = sccs.iter().find(|s| s.len() >= 3);
889 assert!(large_scc.is_some(), "a->b->c->a should be one SCC");
890 }
891
892 #[test]
893 fn test_tarjan_scc_no_cycle() {
894 let edges = vec![
895 make_edge("a", "b", "links", "EXTRACTED"),
896 make_edge("b", "c", "links", "EXTRACTED"),
897 ];
898 let analyzer = Analyzer;
899 let sccs = analyzer.tarjan_scc(&edges);
900 for scc in &sccs {
901 assert_eq!(scc.len(), 1, "DAG should only have singleton SCCs");
902 }
903 }
904
905 #[test]
906 fn test_tarjan_scc_empty() {
907 let analyzer = Analyzer;
908 let sccs = analyzer.tarjan_scc(&[]);
909 assert!(sccs.is_empty());
910 }
911
912 #[test]
913 fn test_bridge_edges_simple() {
914 let nodes = vec![make_node_id("a"), make_node_id("b"), make_node_id("c")];
915 let edges = vec![
916 make_edge("a", "b", "connects", "EXTRACTED"),
917 make_edge("b", "c", "connects", "EXTRACTED"),
918 ];
919 let analyzer = Analyzer;
920 let bridges = analyzer.bridge_edges(&nodes, &edges);
921 assert_eq!(bridges.len(), 2, "both edges are bridges");
922 }
923
924 #[test]
925 fn test_bridge_edges_cycle() {
926 let nodes = vec![make_node_id("a"), make_node_id("b"), make_node_id("c")];
927 let edges = vec![
928 make_edge("a", "b", "connects", "EXTRACTED"),
929 make_edge("b", "c", "connects", "EXTRACTED"),
930 make_edge("c", "a", "connects", "EXTRACTED"),
931 ];
932 let analyzer = Analyzer;
933 let bridges = analyzer.bridge_edges(&nodes, &edges);
934 assert_eq!(bridges.len(), 0, "cycle has no bridges");
935 }
936
937 #[test]
938 fn test_bridge_edges_empty() {
939 let analyzer = Analyzer;
940 let bridges = analyzer.bridge_edges(&[], &[]);
941 assert!(bridges.is_empty());
942 }
943
944 fn make_file_node(id: &str) -> Node {
945 Node {
946 id: id.to_string(),
947 label: id.to_string(),
948 file_type: "file".to_string(),
949 source_file: format!("{id}.py"),
950 source_location: None,
951 community: None,
952 rationale: None,
953 docstring: None,
954 metadata: HashMap::new(),
955 }
956 }
957
958 #[test]
959 fn test_risk_scores_bridge_factor() {
960 let mut nodes = vec![
962 make_file_node("a"),
963 make_file_node("b"),
964 make_file_node("c"),
965 ];
966 let edges = vec![
967 make_edge("a", "b", "imports", "EXTRACTED"),
968 make_edge("b", "c", "imports", "EXTRACTED"),
969 ];
970 let churn_map: HashMap<String, usize> = [
971 ("a".to_string(), 2),
972 ("b".to_string(), 3),
973 ("c".to_string(), 1),
974 ]
975 .into_iter()
976 .collect();
977 let analyzer = Analyzer;
978 analyzer.apply_risk_scores(&mut nodes, &edges, &churn_map);
979
980 let risk_a: f64 = nodes[0].metadata["risk_score"].parse().unwrap();
984 let risk_b: f64 = nodes[1].metadata["risk_score"].parse().unwrap();
985 let risk_c: f64 = nodes[2].metadata["risk_score"].parse().unwrap();
986 assert!((risk_a - 4.0).abs() < 0.01, "a risk={risk_a}");
987 assert!((risk_b - 18.0).abs() < 0.01, "b risk={risk_b}");
988 assert!((risk_c - 2.0).abs() < 0.01, "c risk={risk_c}");
989 }
990
991 #[test]
992 fn test_risk_scores_no_bridges_in_cycle() {
993 let mut nodes = vec![
995 make_file_node("a"),
996 make_file_node("b"),
997 make_file_node("c"),
998 ];
999 let edges = vec![
1000 make_edge("a", "b", "connects", "EXTRACTED"),
1001 make_edge("b", "c", "connects", "EXTRACTED"),
1002 make_edge("c", "a", "connects", "EXTRACTED"),
1003 ];
1004 let churn_map: HashMap<String, usize> = [
1005 ("a".to_string(), 2),
1006 ("b".to_string(), 2),
1007 ("c".to_string(), 2),
1008 ]
1009 .into_iter()
1010 .collect();
1011 let analyzer = Analyzer;
1012 analyzer.apply_risk_scores(&mut nodes, &edges, &churn_map);
1013 for node in &nodes {
1015 let risk: f64 = node.metadata["risk_score"].parse().unwrap();
1016 assert!((risk - 4.0).abs() < 0.01, "{} risk={risk}", node.id);
1017 }
1018 }
1019
1020 #[test]
1021 fn test_risk_scores_skips_non_file_nodes() {
1022 let mut nodes = vec![make_node_id("a")]; let churn_map: HashMap<String, usize> = [("a".to_string(), 5)].into_iter().collect();
1024 let analyzer = Analyzer;
1025 analyzer.apply_risk_scores(&mut nodes, &[], &churn_map);
1026 assert!(!nodes[0].metadata.contains_key("risk_score"));
1027 }
1028
1029 #[test]
1030 fn test_risk_scores_rationale_contains_bridge_factor() {
1031 let mut nodes = vec![make_file_node("a"), make_file_node("b")];
1032 let edges = vec![make_edge("a", "b", "imports", "EXTRACTED")];
1033 let churn_map: HashMap<String, usize> = [("a".to_string(), 1), ("b".to_string(), 1)]
1034 .into_iter()
1035 .collect();
1036 let analyzer = Analyzer;
1037 analyzer.apply_risk_scores(&mut nodes, &edges, &churn_map);
1038 for node in &nodes {
1039 let rationale = node.rationale.as_deref().unwrap_or("");
1040 assert!(
1041 rationale.contains("bridge_factor="),
1042 "rationale missing bridge_factor: {rationale}"
1043 );
1044 }
1045 }
1046
1047 #[test]
1048 fn test_topological_sort_simple() {
1049 let edges = vec![
1050 make_edge("a", "b", "depends", "EXTRACTED"),
1051 make_edge("b", "c", "depends", "EXTRACTED"),
1052 ];
1053 let analyzer = Analyzer;
1054 let sorted = analyzer.topological_sort(&edges);
1055 let pos = |id: &str| sorted.iter().position(|s| s == id).unwrap();
1056 assert!(pos("a") < pos("b"), "a should come before b");
1057 assert!(pos("b") < pos("c"), "b should come before c");
1058 }
1059
1060 #[test]
1061 fn test_topological_sort_empty() {
1062 let analyzer = Analyzer;
1063 let sorted = analyzer.topological_sort(&[]);
1064 assert!(sorted.is_empty());
1065 }
1066
1067 #[test]
1068 fn test_topological_sort_isolated() {
1069 let edges = vec![make_edge("a", "b", "depends", "EXTRACTED")];
1070 let analyzer = Analyzer;
1071 let sorted = analyzer.topological_sort(&edges);
1072 assert_eq!(sorted.len(), 2);
1073 assert_eq!(sorted[0], "a");
1074 assert_eq!(sorted[1], "b");
1075 }
1076}