Skip to main content

code_split_core/
hk.rs

1// Import from the defining modules (not the crate-root re-exports) so this module
2// depends "down" on `graph`/`snapshot` rather than "up" on the crate root — which
3// would close a `root → hk → root` cycle.
4use crate::graph::{Complexity, Coupling, EdgeKind, Graph, Loc, NodeId, NodeKind};
5use crate::snapshot::PluginGraphs;
6use std::collections::{HashMap, HashSet};
7
8pub fn annotate_hk(graphs: &mut PluginGraphs) {
9    annotate_graph_hk(&mut graphs.files);
10}
11
12/// Is this node an external dependency (a library node, not a project file)?
13fn is_external(node: &crate::graph::Node) -> bool {
14    node.kind == NodeKind::External || node.external.unwrap_or(false)
15}
16
17fn annotate_graph_hk(graph: &mut Graph) {
18    // Edges into external libraries are tracked separately (`fan_out_external`)
19    // and excluded from the internal fan-in/out that drives HK — HK measures
20    // *internal* architectural coupling, not 3rd-party library usage.
21    let external_ids: HashSet<&str> = graph
22        .nodes
23        .iter()
24        .filter(|n| is_external(n))
25        .map(|n| n.id.as_str())
26        .collect();
27
28    let mut fan_in: HashMap<NodeId, HashSet<NodeId>> = HashMap::new();
29    let mut fan_out: HashMap<NodeId, HashSet<NodeId>> = HashMap::new();
30    let mut fan_out_ext: HashMap<NodeId, HashSet<NodeId>> = HashMap::new();
31
32    for edge in &graph.edges {
33        // `Contains` edges (Rust `mod foo;` declarations) are structural
34        // ownership, not information flow — excluded from coupling.
35        if edge.kind == EdgeKind::Contains {
36            continue;
37        }
38        // External-library edges are split out into `fan_out_external` (below);
39        // the rest (`uses`/`reexports`) count toward internal coupling.
40        let to_external = external_ids.contains(edge.to.as_str());
41        let from_external = external_ids.contains(edge.from.as_str());
42        if to_external {
43            fan_out_ext
44                .entry(edge.from.clone())
45                .or_default()
46                .insert(edge.to.clone());
47            continue;
48        }
49        if from_external {
50            continue; // edges originating from a library are not project coupling
51        }
52        fan_out
53            .entry(edge.from.clone())
54            .or_default()
55            .insert(edge.to.clone());
56        fan_in
57            .entry(edge.to.clone())
58            .or_default()
59            .insert(edge.from.clone());
60    }
61
62    for node in &mut graph.nodes {
63        if is_external(node) {
64            continue; // library nodes carry no coupling/HK metrics
65        }
66        let fi = fan_in.get(&node.id).map(|s| s.len()).unwrap_or(0);
67        let fo = fan_out.get(&node.id).map(|s| s.len()).unwrap_or(0);
68        let foe = fan_out_ext.get(&node.id).map(|s| s.len()).unwrap_or(0);
69        let struct_loc = node.loc; // structural LOC, present on aggregate (crate) nodes
70
71        let cx = node.complexity.get_or_insert_with(Complexity::default);
72        // When rust-code-analysis produced no LOC (e.g. synthetic crate nodes) but a
73        // structural line count exists, mirror it into `complexity.loc` so the displayed
74        // loc and hk always agree instead of one being blank.
75        if cx.loc.is_none()
76            && let Some(n) = struct_loc
77            && n > 0
78        {
79            cx.loc = Some(Loc {
80                source: n as f64,
81                logical: 0.0,
82                comments: 0.0,
83                blank: 0.0,
84            });
85        }
86        // Henry-Kafura: hk = loc × (fan_in × fan_out)². Uses the same loc that is
87        // displayed; with no loc or no in/out coupling, hk is 0.
88        let loc = cx.loc.as_ref().map(|l| l.source).unwrap_or(0.0);
89        let hk = loc * ((fi * fo) as f64).powi(2);
90        cx.coupling = Some(Coupling {
91            fan_in: fi as u32,
92            fan_out: fo as u32,
93            fan_out_external: foe as u32,
94            hk,
95        });
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::graph::{Edge, EdgeKind, Loc, Node};
103
104    fn module(id: &str, complexity_loc: Option<f64>, struct_loc: Option<u32>) -> Node {
105        Node {
106            id: id.into(),
107            kind: NodeKind::Module,
108            name: id.into(),
109            path: "p".into(),
110            parent: None,
111            external: None,
112            version: None,
113            visibility: None,
114            loc: struct_loc,
115            line: None,
116            item_count: None,
117            method_count: None,
118            complexity: complexity_loc.map(|s| Complexity {
119                loc: Some(Loc {
120                    source: s,
121                    logical: 0.0,
122                    comments: 0.0,
123                    blank: 0.0,
124                }),
125                ..Default::default()
126            }),
127            cycle_kind: None,
128        }
129    }
130
131    fn uses(from: &str, to: &str) -> Edge {
132        Edge {
133            from: from.into(),
134            to: to.into(),
135            kind: EdgeKind::Uses,
136            unresolved: None,
137            external: None,
138            visibility: None,
139        }
140    }
141
142    fn coupling<'a>(g: &'a Graph, id: &str) -> &'a Coupling {
143        g.nodes
144            .iter()
145            .find(|n| n.id == id)
146            .unwrap()
147            .complexity
148            .as_ref()
149            .unwrap()
150            .coupling
151            .as_ref()
152            .unwrap()
153    }
154
155    #[test]
156    fn hk_is_loc_times_fan_squared() {
157        // A -> B -> C.  B has loc 10, fan_in 1, fan_out 1 → hk = 10·(1·1)² = 10.
158        let mut g = PluginGraphs::default();
159        g.files.nodes = vec![
160            module("A", Some(4.0), Some(4)),
161            module("B", Some(10.0), Some(10)),
162            module("C", Some(5.0), Some(5)),
163        ];
164        g.files.edges = vec![uses("A", "B"), uses("B", "C")];
165        annotate_graph_hk(&mut g.files);
166
167        let b = coupling(&g.files, "B");
168        assert_eq!((b.fan_in, b.fan_out), (1, 1));
169        assert_eq!(b.hk, 10.0, "hk = loc(10) · (fan_in·fan_out)²");
170    }
171
172    #[test]
173    fn hk_falls_back_to_structural_loc_for_crate_like_nodes() {
174        // Y -> X -> Z.  X is crate-like: only structural node.loc, no complexity.loc.
175        // It must keep an hk (fan_in 1, fan_out 1) AND surface that loc in complexity.loc.
176        let mut g = PluginGraphs::default();
177        g.files.nodes = vec![
178            module("X", None, Some(10)),
179            module("Y", Some(5.0), Some(5)),
180            module("Z", Some(5.0), Some(5)),
181        ];
182        g.files.edges = vec![uses("Y", "X"), uses("X", "Z")];
183        annotate_graph_hk(&mut g.files);
184
185        let x = g.files.nodes.iter().find(|n| n.id == "X").unwrap();
186        let xc = x.complexity.as_ref().unwrap();
187        assert_eq!(
188            xc.loc.as_ref().unwrap().source,
189            10.0,
190            "structural loc mirrored into complexity.loc so it is displayed"
191        );
192        let cp = xc.coupling.as_ref().unwrap();
193        assert_eq!((cp.fan_in, cp.fan_out), (1, 1));
194        assert_eq!(
195            cp.hk, 10.0,
196            "crate-like node keeps hk from its structural loc"
197        );
198    }
199
200    #[test]
201    fn hk_is_zero_without_any_loc() {
202        // M -> N : M has neither complexity.loc nor structural loc → hk 0 despite fan_out.
203        let mut g = PluginGraphs::default();
204        g.files.nodes = vec![module("M", None, None), module("N", Some(3.0), Some(3))];
205        g.files.edges = vec![uses("M", "N")];
206        annotate_graph_hk(&mut g.files);
207        let m = coupling(&g.files, "M");
208        assert_eq!(m.fan_out, 1);
209        assert_eq!(m.hk, 0.0, "no loc anywhere → hk 0");
210    }
211}