Skip to main content

gitcortex_mcp/mcp/
centrality.rs

1//! Pure in-degree centrality over `Calls` edges — shared by `tour.rs` (which
2//! uses it to rank entry points) and `find_god_nodes` (which surfaces it
3//! directly as named hub detection, GitCortex's no-LLM answer to what other
4//! tools call "god object" / "god node" detection).
5//!
6//! No clustering here — see `clustering.rs` for label-propagation community
7//! detection, which is a separate, coarser-grained signal.
8
9use gitcortex_core::{error::Result, store::GraphStore};
10use serde::Serialize;
11
12// Re-export so clustering.rs and tour.rs can import from here without
13// knowing about the core crate's module layout.
14pub use gitcortex_core::graph::in_degree_by_calls;
15
16#[derive(Debug, Clone, Serialize)]
17pub struct GodNode {
18    pub name: String,
19    pub qualified_name: String,
20    pub kind: String,
21    pub file: String,
22    pub start_line: u32,
23    pub in_degree: u32,
24}
25
26/// Default floor: a symbol needs at least this many inbound calls to count
27/// as a hub. Chosen to sit above "normal" fan-in for a small/medium repo
28/// without requiring per-repo tuning.
29const DEFAULT_MIN_IN_DEGREE: u32 = 10;
30const DEFAULT_LIMIT: usize = 20;
31const MAX_LIMIT: usize = 100;
32
33/// Find high-fan-in "hub" symbols — functions/methods many other symbols
34/// call into. Deterministic: ranked by in-degree descending, ties broken by
35/// `qualified_name` ascending, so re-running on the same indexed state
36/// always produces byte-identical output.
37pub fn find_god_nodes<S: GraphStore + ?Sized>(
38    store: &S,
39    branch: &str,
40    min_in_degree: Option<u32>,
41    limit: Option<usize>,
42) -> Result<Vec<GodNode>> {
43    let min_in_degree = min_in_degree.unwrap_or(DEFAULT_MIN_IN_DEGREE);
44    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
45
46    let nodes = store.list_all_nodes(branch)?;
47    let edges = store.list_all_edges(branch)?;
48    let in_degree = in_degree_by_calls(&edges);
49
50    let mut scored: Vec<(GodNode, u32)> = nodes
51        .into_iter()
52        .filter_map(|n| {
53            let deg = in_degree.get(&n.id.as_str()).copied().unwrap_or(0);
54            if deg < min_in_degree {
55                return None;
56            }
57            Some((
58                GodNode {
59                    name: n.name,
60                    qualified_name: n.qualified_name,
61                    kind: n.kind.to_string(),
62                    file: n.file.display().to_string(),
63                    start_line: n.span.start_line,
64                    in_degree: deg,
65                },
66                deg,
67            ))
68        })
69        .collect();
70
71    scored.sort_by(|a, b| {
72        b.1.cmp(&a.1)
73            .then_with(|| a.0.qualified_name.cmp(&b.0.qualified_name))
74    });
75
76    Ok(scored.into_iter().take(limit).map(|(g, _)| g).collect())
77}
78
79#[cfg(test)]
80mod tests {
81    use gitcortex_core::graph::{Edge, NodeId};
82    use gitcortex_core::schema::EdgeKind;
83
84    use super::in_degree_by_calls;
85
86    #[test]
87    fn in_degree_empty_edges_returns_empty_map() {
88        assert!(in_degree_by_calls(&[]).is_empty());
89    }
90
91    #[test]
92    fn in_degree_counts_calls_edges_only() {
93        let a = NodeId::new();
94        let b = NodeId::new();
95        let edges = vec![
96            Edge::new(a.clone(), b.clone(), EdgeKind::Calls),
97            Edge::new(a.clone(), b.clone(), EdgeKind::Contains), // should not count
98            Edge::new(a.clone(), b.clone(), EdgeKind::Uses),     // should not count
99        ];
100        let map = in_degree_by_calls(&edges);
101        assert_eq!(map.get(&b.as_str()), Some(&1));
102        assert!(
103            !map.contains_key(&a.as_str()),
104            "src should not appear as dst"
105        );
106    }
107
108    #[test]
109    fn in_degree_multiple_callers_accumulate() {
110        let dst = NodeId::new();
111        let callers: Vec<NodeId> = (0..5).map(|_| NodeId::new()).collect();
112        let edges: Vec<Edge> = callers
113            .iter()
114            .map(|src| Edge::new(src.clone(), dst.clone(), EdgeKind::Calls))
115            .collect();
116        let map = in_degree_by_calls(&edges);
117        assert_eq!(map.get(&dst.as_str()), Some(&5));
118    }
119
120    #[test]
121    fn in_degree_src_node_absent_unless_also_dst() {
122        let a = NodeId::new();
123        let b = NodeId::new();
124        let c = NodeId::new();
125        // a→b, b→c: b is both src and dst
126        let edges = vec![
127            Edge::new(a.clone(), b.clone(), EdgeKind::Calls),
128            Edge::new(b.clone(), c.clone(), EdgeKind::Calls),
129        ];
130        let map = in_degree_by_calls(&edges);
131        assert!(
132            !map.contains_key(&a.as_str()),
133            "pure caller should not appear"
134        );
135        assert_eq!(map.get(&b.as_str()), Some(&1));
136        assert_eq!(map.get(&c.as_str()), Some(&1));
137    }
138
139    #[test]
140    fn in_degree_sort_order_descending_ties_by_qname() {
141        // Verify find_god_nodes ordering contract via in_degree values directly.
142        let high = NodeId::new();
143        let low = NodeId::new();
144        let callers: Vec<NodeId> = (0..3).map(|_| NodeId::new()).collect();
145        let mut edges: Vec<Edge> = callers
146            .iter()
147            .map(|src| Edge::new(src.clone(), high.clone(), EdgeKind::Calls))
148            .collect();
149        edges.push(Edge::new(NodeId::new(), low.clone(), EdgeKind::Calls));
150        let map = in_degree_by_calls(&edges);
151        assert!(map[&high.as_str()] > map[&low.as_str()]);
152    }
153}