Skip to main content

codesynapse_core/
analyze.rs

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, &deg) 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    /// Compute temporal risk for file nodes based on git history.
496    ///
497    /// For each file node, the risk score is:
498    ///   risk_score = churn_rate * degree_centrality * community_bridge_factor
499    /// where:
500    ///   - churn_rate: number of commits that touched the file (`git rev-list --count HEAD -- <file>`)
501    ///   - degree_centrality: number of edges connected to the node
502    ///   - community_bridge_factor: 1.0 + number of bridge edges the node participates in
503    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    /// Apply risk scores using pre-computed churn values. Separated for testability.
544    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        // ".foo()" method stub and isolated "bar()" function stub (degree 1) must be excluded
692        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            // stub_fn has degree 1 (only one edge)
702            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        // Linear chain a→b→c: both edges are bridges; b is endpoint of 2 bridges
961        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        // a: churn=2, deg=1, bridge_factor=1+1=2.0 → 4.0
981        // b: churn=3, deg=2, bridge_factor=1+2=3.0 → 18.0
982        // c: churn=1, deg=1, bridge_factor=1+1=2.0 → 2.0
983        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        // Cycle a→b→c→a: no bridge edges → bridge_factor=1.0 for all
994        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        // Each node: churn=2, deg=2, bridge_factor=1.0 → 4.0
1014        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")]; // file_type = "code"
1023        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}