Skip to main content

srcgraph_metrics/
lcom4.rs

1//! LCOM4 cohesion — connected components on the per-class method-field
2//! bipartite graph. LCOM4 = number of components (1 = cohesive).
3//!
4//! See `DESIGN.md` (Phase 1) at the workspace root.
5//!
6//! @yah:ticket(R152-T3, "metrics::lcom4 — connected components on per-class method-field bipartite graph")
7//! @yah:assignee(agent:claude)
8//! @yah:at(2026-05-12T22:14:28Z)
9//! @yah:status(review)
10//! @yah:parent(R152)
11//! @yah:verify("cargo test -p srcgraph-metrics lcom4")
12//! @yah:handoff("Implemented LCOM4: build_method_graph (UnGraph from MethodConnectivity), compute_lcom4 (petgraph connected_components, None for empty), compute_lcom4_all (per-class scores generic over ClassNode+EdgeKind; None when methodConnectivity is absent). 6 tests pass; covers cohesive, disjoint, isolated, empty, ghost-endpoint, and mixed-graph cases.")
13
14use petgraph::algo::connected_components;
15use petgraph::graph::UnGraph;
16use petgraph::Graph;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20use srcgraph_core::{ClassNode, EdgeKind, MethodConnectivity};
21
22/// LCOM4 score for a single class.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct LcomScore {
25    /// `ClassNode::id()` of the class this score belongs to.
26    pub class_id: String,
27    /// Number of connected components in the method graph.
28    /// `None` when the class has no `methodConnectivity` (syntax-only extraction)
29    /// or when its method set is empty (LCOM4 is undefined).
30    pub lcom4: Option<usize>,
31}
32
33/// Build the undirected method graph from a [`MethodConnectivity`] record.
34///
35/// Node set is `conn.methods`; edge set is `conn.edges`. Edge endpoints that
36/// are not listed in `conn.methods` are added as additional nodes (matches the
37/// permissive behaviour of `nx.Graph.add_edges_from`).
38pub fn build_method_graph(conn: &MethodConnectivity) -> UnGraph<String, ()> {
39    let mut g: UnGraph<String, ()> = UnGraph::new_undirected();
40    let mut idx: HashMap<&str, _> = HashMap::with_capacity(conn.methods.len());
41    for m in &conn.methods {
42        let id = g.add_node(m.clone());
43        idx.insert(m.as_str(), id);
44    }
45    for (a, b) in &conn.edges {
46        let ia = *idx
47            .entry(a.as_str())
48            .or_insert_with(|| g.add_node(a.clone()));
49        let ib = *idx
50            .entry(b.as_str())
51            .or_insert_with(|| g.add_node(b.clone()));
52        g.add_edge(ia, ib, ());
53    }
54    g
55}
56
57/// Connected-component count on an undirected method graph.
58///
59/// Returns `None` when the graph is empty — LCOM4 is undefined for a class
60/// with no methods, matching the `ValueError` raised by the Python reference
61/// implementation.
62pub fn compute_lcom4(method_graph: &UnGraph<String, ()>) -> Option<usize> {
63    if method_graph.node_count() == 0 {
64        return None;
65    }
66    Some(connected_components(method_graph))
67}
68
69/// Compute LCOM4 for every class node in `graph`.
70///
71/// Classes without a `methodConnectivity` attribute yield `LcomScore { lcom4: None }`
72/// so callers can distinguish "no data" from "1 component". Output is in node-index
73/// order so it lines up with `graph.node_indices()`.
74pub fn compute_lcom4_all<N, E>(graph: &Graph<N, E>) -> Vec<LcomScore>
75where
76    N: ClassNode,
77    E: EdgeKind,
78{
79    graph
80        .node_indices()
81        .map(|nx| {
82            let node = &graph[nx];
83            let lcom4 = node
84                .method_connectivity()
85                .and_then(|conn| compute_lcom4(&build_method_graph(conn)));
86            LcomScore {
87                class_id: node.id().to_owned(),
88                lcom4,
89            }
90        })
91        .collect()
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use srcgraph_core::{EdgeType, OwnedClassNode, OwnedGraph};
98    use petgraph::Graph;
99
100    fn class(id: &str, conn: Option<MethodConnectivity>) -> OwnedClassNode {
101        OwnedClassNode {
102            id: id.to_owned(),
103            name: id.split('.').last().unwrap_or(id).to_owned(),
104            namespace: "test".to_owned(),
105            line_count: 10,
106            method_count: conn.as_ref().map_or(0, |c| c.methods.len() as u32),
107            halstead_eta1: 0,
108            halstead_eta2: 0,
109            halstead_n1: 0,
110            halstead_n2: 0,
111            method_connectivity: conn,
112            method_fingerprints: None,
113            method_tokens: None,
114            call_sequences: None,
115            cyclomatic_complexity: None,
116            path_conditions: None,
117            invariants: None,
118            error_messages: None,
119            magic_numbers: None,
120            dead_code: None,
121            tenant_branches: None,
122            state_transitions: None,
123        }
124    }
125
126    fn conn(methods: &[&str], edges: &[(&str, &str)]) -> MethodConnectivity {
127        MethodConnectivity {
128            methods: methods.iter().map(|s| s.to_string()).collect(),
129            edges: edges
130                .iter()
131                .map(|(a, b)| (a.to_string(), b.to_string()))
132                .collect(),
133        }
134    }
135
136    #[test]
137    fn lcom4_single_cohesive_component() {
138        // foo–bar–baz all chained: one component, cohesive.
139        let mg = build_method_graph(&conn(
140            &["foo", "bar", "baz"],
141            &[("foo", "bar"), ("bar", "baz")],
142        ));
143        assert_eq!(compute_lcom4(&mg), Some(1));
144    }
145
146    #[test]
147    fn lcom4_two_disjoint_clusters() {
148        // foo–bar and baz–qux — two responsibility clusters.
149        let mg = build_method_graph(&conn(
150            &["foo", "bar", "baz", "qux"],
151            &[("foo", "bar"), ("baz", "qux")],
152        ));
153        assert_eq!(compute_lcom4(&mg), Some(2));
154    }
155
156    #[test]
157    fn lcom4_isolated_methods_each_count() {
158        // Three methods, no shared fields → three components.
159        let mg = build_method_graph(&conn(&["foo", "bar", "baz"], &[]));
160        assert_eq!(compute_lcom4(&mg), Some(3));
161    }
162
163    #[test]
164    fn lcom4_empty_is_undefined() {
165        let mg = build_method_graph(&conn(&[], &[]));
166        assert_eq!(compute_lcom4(&mg), None);
167    }
168
169    #[test]
170    fn lcom4_edge_endpoint_outside_methods_list() {
171        // Edge references a method not listed in `methods` — petgraph/nx both
172        // treat it as an additional node; we mirror that.
173        let mg = build_method_graph(&conn(&["foo"], &[("foo", "ghost")]));
174        assert_eq!(compute_lcom4(&mg), Some(1));
175        assert_eq!(mg.node_count(), 2);
176    }
177
178    #[test]
179    fn lcom4_all_skips_classes_without_connectivity() {
180        let mut g: OwnedGraph = Graph::new();
181        let a = g.add_node(class(
182            "A",
183            Some(conn(&["m1", "m2"], &[("m1", "m2")])),
184        ));
185        let b = g.add_node(class("B", None));
186        let c = g.add_node(class(
187            "C",
188            Some(conn(&["x", "y", "z"], &[])),
189        ));
190        g.add_edge(a, b, EdgeType::MethodCall);
191        g.add_edge(b, c, EdgeType::Inheritance);
192
193        let scores = compute_lcom4_all(&g);
194        assert_eq!(scores.len(), 3);
195
196        let by_id: HashMap<_, _> = scores
197            .iter()
198            .map(|s| (s.class_id.as_str(), s.lcom4))
199            .collect();
200        assert_eq!(by_id["A"], Some(1));
201        assert_eq!(by_id["B"], None, "no methodConnectivity → None");
202        assert_eq!(by_id["C"], Some(3), "three isolated methods");
203    }
204}