From 853bfbc4271852cdec10fbffd37a9d33ac11d748 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Thu, 2 Apr 2026 02:29:07 -0400 Subject: [PATCH] feat(notgraph): implement analysis (fan stats, Kahn cycles, hotspots) --- crates/notgraph/src/analysis.rs | 166 ++++++++++++++++++ crates/notgraph/src/lib.rs | 1 + .../notgraph/tests/fixtures/cyclic/src/bar.rs | 1 + .../notgraph/tests/fixtures/cyclic/src/foo.rs | 1 + .../notgraph/tests/fixtures/cyclic/src/lib.rs | 2 + 5 files changed, 171 insertions(+) create mode 100644 crates/notgraph/src/analysis.rs create mode 100644 crates/notgraph/tests/fixtures/cyclic/src/bar.rs create mode 100644 crates/notgraph/tests/fixtures/cyclic/src/foo.rs create mode 100644 crates/notgraph/tests/fixtures/cyclic/src/lib.rs diff --git a/crates/notgraph/src/analysis.rs b/crates/notgraph/src/analysis.rs new file mode 100644 index 0000000..1e69a85 --- /dev/null +++ b/crates/notgraph/src/analysis.rs @@ -0,0 +1,166 @@ +use crate::types::{ + CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph, +}; +use std::collections::{HashMap, VecDeque}; + +pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec { + let mut fan_in: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect(); + let mut fan_out: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect(); + for (from, to) in edges { + *fan_out.entry(from.as_str()).or_insert(0) += 1; + *fan_in.entry(to.as_str()).or_insert(0) += 1; + } + nodes.iter().map(|n| FanStats { + name: n.clone(), + fan_in: *fan_in.get(n.as_str()).unwrap_or(&0), + fan_out: *fan_out.get(n.as_str()).unwrap_or(&0), + }).collect() +} + +/// Kahn's algorithm. Returns cycle participants if graph is not a DAG. +pub fn detect_cycles(nodes: &[String], edges: &[(String, String)]) -> Vec> { + let mut in_degree: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect(); + let mut adj: HashMap<&str, Vec<&str>> = nodes.iter().map(|n| (n.as_str(), vec![])).collect(); + + for (from, to) in edges { + *in_degree.entry(to.as_str()).or_insert(0) += 1; + adj.entry(from.as_str()).or_default().push(to.as_str()); + } + + let mut queue: VecDeque<&str> = in_degree.iter() + .filter(|&(_, &d)| d == 0) + .map(|(n, _)| *n) + .collect(); + + let mut visited = 0usize; + while let Some(node) = queue.pop_front() { + visited += 1; + for &neighbour in adj.get(node).unwrap_or(&vec![]) { + let deg = in_degree.entry(neighbour).or_insert(0); + *deg -= 1; + if *deg == 0 { + queue.push_back(neighbour); + } + } + } + + if visited == nodes.len() { + return vec![]; + } + + let cycle_nodes: Vec = in_degree.iter() + .filter(|&(_, &d)| d > 0) + .map(|(n, _)| n.to_string()) + .collect(); + + vec![cycle_nodes] +} + +pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec { + let mut result = Vec::new(); + + let mut by_fan_in = stats.to_vec(); + by_fan_in.sort_by(|a, b| b.fan_in.cmp(&a.fan_in)); + for s in by_fan_in.iter().take(top_n) { + if s.fan_in > 0 { + result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanIn, score: s.fan_in }); + } + } + + let mut by_fan_out = stats.to_vec(); + by_fan_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out)); + for s in by_fan_out.iter().take(top_n) { + if s.fan_out > 0 { + result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanOut, score: s.fan_out }); + } + } + + result +} + +pub fn analyse(crate_graph: &CrateGraph, module_graphs: &[ModuleGraph], top_n: usize) -> GraphStats { + let crate_stats = fan_stats(&crate_graph.nodes, &crate_graph.edges); + let crate_hotspots = hotspots(&crate_stats, top_n); + + let mut all_cycles: Vec> = Vec::new(); + let mut mod_stats_list: Vec = Vec::new(); + + for mg in module_graphs { + let node_stats = fan_stats(&mg.nodes, &mg.edges); + let cycles = detect_cycles(&mg.nodes, &mg.edges); + all_cycles.extend(cycles.clone()); + mod_stats_list.push(ModStats { krate: mg.krate.clone(), nodes: node_stats, cycles }); + } + + let all_mod_fan: Vec = mod_stats_list.iter() + .flat_map(|ms| ms.nodes.iter().cloned()) + .collect(); + let mod_hotspots = hotspots(&all_mod_fan, top_n); + + let mut all_hotspots = crate_hotspots; + all_hotspots.extend(mod_hotspots); + + GraphStats { + crate_graph: crate_stats, + module_graphs: mod_stats_list, + hotspots: all_hotspots, + cycles: all_cycles, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fan_stats_counts_correctly() { + let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let edges = vec![ + ("a".to_string(), "b".to_string()), + ("a".to_string(), "c".to_string()), + ("b".to_string(), "c".to_string()), + ]; + let stats = fan_stats(&nodes, &edges); + let a = stats.iter().find(|s| s.name == "a").unwrap(); + let c = stats.iter().find(|s| s.name == "c").unwrap(); + assert_eq!(a.fan_out, 2); + assert_eq!(a.fan_in, 0); + assert_eq!(c.fan_in, 2); + assert_eq!(c.fan_out, 0); + } + + #[test] + fn no_cycle_in_acyclic_graph() { + let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let edges = vec![ + ("a".to_string(), "b".to_string()), + ("b".to_string(), "c".to_string()), + ]; + assert!(detect_cycles(&nodes, &edges).is_empty()); + } + + #[test] + fn detects_cycle_in_cyclic_graph() { + let nodes = vec!["a".to_string(), "b".to_string()]; + let edges = vec![ + ("a".to_string(), "b".to_string()), + ("b".to_string(), "a".to_string()), + ]; + let cycles = detect_cycles(&nodes, &edges); + assert!(!cycles.is_empty()); + } + + #[test] + fn hotspots_picks_top_n_by_fan_in_and_fan_out() { + let stats = vec![ + FanStats { name: "a".to_string(), fan_in: 5, fan_out: 1 }, + FanStats { name: "b".to_string(), fan_in: 3, fan_out: 2 }, + FanStats { name: "c".to_string(), fan_in: 1, fan_out: 4 }, + ]; + let spots = hotspots(&stats, 1); + let fi = spots.iter().find(|h| h.kind == HotspotKind::FanIn).unwrap(); + let fo = spots.iter().find(|h| h.kind == HotspotKind::FanOut).unwrap(); + assert_eq!(fi.name, "a"); + assert_eq!(fo.name, "c"); + } +} diff --git a/crates/notgraph/src/lib.rs b/crates/notgraph/src/lib.rs index 291f752..623dd2d 100644 --- a/crates/notgraph/src/lib.rs +++ b/crates/notgraph/src/lib.rs @@ -1,3 +1,4 @@ +pub mod analysis; pub mod crate_graph; pub mod module_graph; pub mod symbols; diff --git a/crates/notgraph/tests/fixtures/cyclic/src/bar.rs b/crates/notgraph/tests/fixtures/cyclic/src/bar.rs new file mode 100644 index 0000000..b52703b --- /dev/null +++ b/crates/notgraph/tests/fixtures/cyclic/src/bar.rs @@ -0,0 +1 @@ +pub mod foo; diff --git a/crates/notgraph/tests/fixtures/cyclic/src/foo.rs b/crates/notgraph/tests/fixtures/cyclic/src/foo.rs new file mode 100644 index 0000000..46f285c --- /dev/null +++ b/crates/notgraph/tests/fixtures/cyclic/src/foo.rs @@ -0,0 +1 @@ +pub mod bar; diff --git a/crates/notgraph/tests/fixtures/cyclic/src/lib.rs b/crates/notgraph/tests/fixtures/cyclic/src/lib.rs new file mode 100644 index 0000000..39afbd5 --- /dev/null +++ b/crates/notgraph/tests/fixtures/cyclic/src/lib.rs @@ -0,0 +1,2 @@ +pub mod foo; +pub mod bar;