Skip to main content

code_repo_wiki/analysis/
module.rs

1use std::collections::HashSet;
2
3use petgraph::visit::{EdgeRef, IntoEdgeReferences};
4
5
6use crate::model::*;
7
8use super::community::{community_name, detect_communities};
9
10/// 模块边界检测器
11pub struct ModuleDetector<'a> {
12    graph: &'a KnowledgeGraph,
13}
14
15impl<'a> ModuleDetector<'a> {
16    pub fn new(graph: &'a KnowledgeGraph) -> Self {
17        Self { graph }
18    }
19
20    /// 执行模块检测,返回模块聚类列表
21    ///
22    /// 算法(演进计划 T1.2):Leiden 社区检测(CPM 质量函数)在跨文件
23    /// Imports/Calls/DependsOn 依赖图上划分 File 节点社区;每个社区命名
24    /// 走 [`community_name`] 三档规则(公共目录前缀 → 文件数最多目录 →
25    /// module_{n}),重名时追加文件 stem 消歧(模块名是 wiki 产物文件名
26    /// 的唯一来源,重名会互相覆盖)。
27    ///
28    /// cohesion/coupling 仅作为**描述性元数据**写入 ModuleCluster(见
29    /// count_edges 注释:历史上阈值拒绝导致"全有或全无"的脆弱分界)。
30    pub fn detect(&self) -> Vec<ModuleCluster> {
31        let communities = detect_communities(self.graph);
32        let mut clusters: Vec<ModuleCluster> = Vec::with_capacity(communities.len());
33        // 已用模块名集合:保证产物路径唯一
34        let mut used_names: HashSet<String> = HashSet::new();
35
36        for (idx, community) in communities.iter().enumerate() {
37            // 命名输入 = 社区内文件路径(确定性:communities 已按大小降序 +
38            // 最小路径排序,组内再排序——file_stem 取 first 的消歧后缀依赖组内顺序,N20)
39            let mut file_paths: Vec<String> = community
40                .iter()
41                .filter_map(|nid| {
42                    self.graph
43                        .graph
44                        .node_weight(*nid)
45                        .and_then(|n| n.file_path.clone())
46                })
47                .collect();
48            file_paths.sort();
49            let mut name = community_name(&file_paths, idx);
50            if used_names.contains(&name) {
51                // 消歧:单文件社区与同目录社区重名时,追加文件 stem
52                if let Some(stem) = file_stem(&file_paths) {
53                    let alt = format!("{name}::{stem}");
54                    if !used_names.contains(&alt) {
55                        name = alt;
56                    } else {
57                        name = format!("module_{idx}");
58                    }
59                } else {
60                    name = format!("module_{idx}");
61                }
62            }
63            used_names.insert(name.clone());
64
65            let cohesion = self.calculate_cohesion(community);
66            let coupling = self.calculate_coupling(community);
67
68            // 扩展:File 节点 + 其直接 Contains 的实体节点(与 count_edges 同一规则,
69            // 但这是**持久化到 ModuleCluster.node_ids** 的集合——api.md 分组、
70            // mermaid 模块图跨模块边聚合都遍历 node_ids,若只含 File 节点则
71            // 实体清单为空、模块图全空)
72            let file_set: HashSet<NodeId> = community.iter().copied().collect();
73            let mut expanded: HashSet<NodeId> = file_set.clone();
74            for edge in self.graph.graph.edge_references() {
75                let kind = self.graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
76                if kind == Some(EdgeKind::Contains) && file_set.contains(&edge.source()) {
77                    expanded.insert(edge.target());
78                }
79            }
80            // 去重排序,保持确定性输出
81            let mut unique: Vec<NodeId> = expanded.into_iter().collect();
82            unique.sort();
83            clusters.push(ModuleCluster {
84                name,
85                node_ids: unique,
86                cohesion,
87                coupling,
88                description: None,
89            });
90        }
91
92        clusters
93    }
94
95    /// 计算模块内聚度(0.0~1.0)
96    fn calculate_cohesion(&self, node_ids: &[NodeId]) -> f64 {
97        let (internal, external) = self.count_edges(node_ids);
98        let total = internal + external;
99        if total == 0.0 {
100            return 0.0;
101        }
102        internal / total
103    }
104
105    /// 计算模块耦合度(0.0~1.0)
106    fn calculate_coupling(&self, node_ids: &[NodeId]) -> f64 {
107        let (internal, external) = self.count_edges(node_ids);
108        let total = internal + external;
109        if total == 0.0 {
110            return 0.0;
111        }
112        external / total
113    }
114
115    /// 统计集合内部和跨集合边数
116    ///
117    /// 关键语义:聚类单位是 File 节点,但调用/导入/实现边都挂在
118    /// **实体节点**上(File 节点只有 Contains 边)。因此先按
119    /// File→Entity 的 Contains 边把集合扩展为"文件 + 其直接包含的实体",
120    /// 边统计才有意义;随后统计时**排除全部 Contains 边**(结构性边,
121    /// 只表达归属,不是依赖关系),否则 Module→File/File→Entity 的
122    /// Contains 会把 external 撑爆、cohesion 恒压到 0,导致任何目录都
123    /// 无法通过阈值(此前模块检测在全量图上恒产出 0 个模块的根因)。
124    fn count_edges(&self, node_ids: &[NodeId]) -> (f64, f64) {
125        // 1. 集合扩展:File 节点 + 其直接 Contains 的实体节点
126        let file_set: HashSet<NodeId> = node_ids.iter().copied().collect();
127        let mut set: HashSet<NodeId> = file_set.clone();
128        for edge in self.graph.graph.edge_references() {
129            let kind = self.graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
130            if kind == Some(EdgeKind::Contains) && file_set.contains(&edge.source()) {
131                set.insert(edge.target());
132            }
133        }
134
135        // 2. 边统计:排除 Contains(结构性),只数依赖类边
136        let mut internal = 0.0;
137        let mut external = 0.0;
138        for edge in self.graph.graph.edge_references() {
139            let kind = self.graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
140            if kind == Some(EdgeKind::Contains) {
141                continue;
142            }
143            let s = edge.source();
144            let t = edge.target();
145            let in_s = set.contains(&s);
146            let in_t = set.contains(&t);
147            if in_s && in_t {
148                internal += 1.0;
149            } else if in_s || in_t {
150                external += 1.0;
151            }
152        }
153        (internal, external)
154    }
155}
156
157/// 取社区内第一个文件的 stem(重名消歧用,如 "src/net/tcp.rs" → "tcp")
158fn file_stem(files: &[String]) -> Option<String> {
159    files
160        .first()
161        .and_then(|p| std::path::Path::new(p).file_stem())
162        .map(|s| s.to_string_lossy().into_owned())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    
169
170    fn make_small_graph() -> KnowledgeGraph {
171        let mut kg = KnowledgeGraph::default();
172        let g = &mut kg.graph;
173        let p = g.add_node(CodeNode {
174            id: NodeId::new(0), kind: NodeKind::Project, name: "p".into(),
175            file_path: None, line_range: None, doc_comment: None,
176            signature: None, module_path: vec![], visibility: None,
177        });
178        let m = g.add_node(CodeNode {
179            id: NodeId::new(1), kind: NodeKind::Module, name: "m".into(),
180            file_path: None, line_range: None, doc_comment: None,
181            signature: None, module_path: vec!["src".into()], visibility: None,
182        });
183        let f1 = g.add_node(CodeNode {
184            id: NodeId::new(2), kind: NodeKind::File, name: "a.rs".into(),
185            file_path: Some("src/a.rs".into()), line_range: None, doc_comment: None,
186            signature: None, module_path: vec!["src".into()], visibility: None,
187        });
188        let f2 = g.add_node(CodeNode {
189            id: NodeId::new(3), kind: NodeKind::File, name: "b.rs".into(),
190            file_path: Some("src/b.rs".into()), line_range: None, doc_comment: None,
191            signature: None, module_path: vec!["src".into()], visibility: None,
192        });
193        let e1 = g.add_node(CodeNode {
194            id: NodeId::new(4), kind: NodeKind::Function, name: "foo".into(),
195            file_path: Some("src/a.rs".into()), line_range: None, doc_comment: None,
196            signature: None, module_path: vec!["src".into(), "a".into()], visibility: None,
197        });
198        let e2 = g.add_node(CodeNode {
199            id: NodeId::new(5), kind: NodeKind::Function, name: "bar".into(),
200            file_path: Some("src/b.rs".into()), line_range: None, doc_comment: None,
201            signature: None, module_path: vec!["src".into(), "b".into()], visibility: None,
202        });
203        // 添加内部 Contains 边
204        for (src, tgt) in &[(p, m), (m, f1), (m, f2), (f1, e1), (f2, e2)] {
205            g.add_edge(*src, *tgt, CodeEdge {
206                id: EdgeId::new(g.edge_count()), kind: EdgeKind::Contains,
207                source: *src, target: *tgt, weight: 1.0, location: None,
208            });
209        }
210        // 内部 Calls 边(e1 → e2)
211        g.add_edge(e1, e2, CodeEdge {
212            id: EdgeId::new(g.edge_count()), kind: EdgeKind::Calls,
213            source: e1, target: e2, weight: 0.7, location: None,
214        });
215        kg
216    }
217
218    #[test]
219    fn test_cohesion() {
220        let kg = make_small_graph();
221        let detector = ModuleDetector::new(&kg);
222        // 只传文件节点(集合会按 Contains 边自动扩展为 文件+实体)
223        let ids = vec![NodeId::new(2), NodeId::new(3)];
224        let c = detector.calculate_cohesion(&ids);
225        // 扩展后 set={f1,f2,e1,e2};内部非 Contains: e1→e2 (Calls) = 1
226        // Contains 结构性边全部排除;无外部边
227        // 总非 Contains = 1, 内聚 = 1.0
228        let expected = 1.0;
229        assert!((c - expected).abs() < 1e-6);
230    }
231
232    #[test]
233    fn test_coupling() {
234        let mut kg = make_small_graph();
235        // 添加外部边(TypeReference 已随未构建边类型删除,改用 Calls)
236        kg.graph.add_edge(
237            NodeId::new(4), NodeId::new(5),
238            CodeEdge {
239                id: EdgeId::new(kg.graph.edge_count()), kind: EdgeKind::Calls,
240                source: NodeId::new(4), target: NodeId::new(5), weight: 0.5, location: None,
241            },
242        );
243        let detector = ModuleDetector::new(&kg);
244        let ids = vec![NodeId::new(2)]; // 只包含 a.rs(扩展后含 foo)
245        let coupling = detector.calculate_coupling(&ids);
246        // 扩展后 set={f1,e1};跨边界: e1→e2 的 Calls 两条(原有 + 新增)→ e2 在集合外 => 2
247        // 总非 Contains = 2; coupling = 2/2 = 1.0
248        dbg!(coupling);
249        assert!((coupling - 1.0).abs() < 1e-6);
250    }
251
252    #[test]
253    fn test_detect() {
254        let kg = make_small_graph();
255        let detector = ModuleDetector::new(&kg);
256        let clusters = detector.detect();
257        // a.rs 与 b.rs 同前缀 "src",内部互调(e1→e2)且无外部依赖:
258        // cohesion=1.0>0.3, coupling=0<0.7 → 应检出 1 个模块
259        assert_eq!(clusters.len(), 1, "应检出 src 模块,实际: {:?}", clusters.iter().map(|c| &c.name).collect::<Vec<_>>());
260        assert_eq!(clusters[0].name, "src");
261        // node_ids = 2 文件 + 2 实体(File→Entity Contains 扩展),
262        // api.md/mermaid 依赖实体节点在集合内
263        assert_eq!(clusters[0].node_ids.len(), 4, "模块应包含 2 文件 + 2 实体节点");
264        // 且 4 个节点确实是 2 File + 2 Function
265        let kinds: Vec<_> = clusters[0]
266            .node_ids
267            .iter()
268            .map(|nid| kg.graph.node_weight(*nid).unwrap().kind.clone())
269            .collect();
270        assert_eq!(
271            kinds.iter().filter(|k| **k == NodeKind::File).count(),
272            2,
273            "应含 2 个文件节点: {:?}",
274            kinds
275        );
276        assert_eq!(
277            kinds.iter().filter(|k| **k == NodeKind::Function).count(),
278            2,
279            "应含 2 个实体节点: {:?}",
280            kinds
281        );
282    }
283
284    /// 多目录社区检测:src/net(2 文件)有跨文件调用 → Leiden 聚为一个社区;
285    /// src/http 两文件互不相连 → 各成单文件社区(同目录重名经文件 stem 消歧)
286    #[test]
287    fn test_detect_multiple_directories() {
288        let mut kg = KnowledgeGraph::default();
289        let g = &mut kg.graph;
290        let add_file = |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>, id: usize, path: &str, segs: Vec<&str>| -> (NodeId, NodeId) {
291            let nid = g.add_node(CodeNode {
292                id: NodeId::new(id), kind: NodeKind::File, name: path.into(),
293                file_path: Some(path.into()), line_range: None, doc_comment: None,
294                signature: None, module_path: segs.iter().map(|s| s.to_string()).collect(), visibility: None,
295            });
296            // File → Entity 的 Contains 边(实体计入模块集合的前提)
297            let eid = g.add_node(CodeNode {
298                id: NodeId::new(id + 100), kind: NodeKind::Function, name: format!("f{id}"),
299                file_path: Some(path.into()), line_range: None, doc_comment: None,
300                signature: None, module_path: segs.iter().map(|s| s.to_string()).collect(), visibility: None,
301            });
302            g.add_edge(nid, eid, CodeEdge {
303                id: EdgeId::new(g.edge_count()), kind: EdgeKind::Contains,
304                source: nid, target: eid, weight: 1.0, location: None,
305            });
306            (nid, eid)
307        };
308        let (_tcp, etcp) = add_file(g, 0, "src/net/tcp.rs", vec!["src", "net"]);
309        let (_udp, eudp) = add_file(g, 1, "src/net/udp.rs", vec!["src", "net"]);
310        let _server = add_file(g, 2, "src/http/server.rs", vec!["src", "http"]);
311        let _client = add_file(g, 3, "src/http/client.rs", vec!["src", "http"]);
312        // tcp 实体 → udp 实体 跨文件调用:net 目录两文件聚为一社区
313        g.add_edge(etcp, eudp, CodeEdge {
314            id: EdgeId::new(g.edge_count()), kind: EdgeKind::Calls,
315            source: etcp, target: eudp, weight: 0.7, location: None,
316        });
317
318        let detector = ModuleDetector::new(&kg);
319        let clusters = detector.detect();
320        let names: Vec<&str> = clusters.iter().map(|c| c.name.as_str()).collect();
321        // 社区检测语义:net 两文件一个社区;http 两文件各成社区(重名消歧)
322        assert!(
323            names.contains(&"src::net"),
324            "应检出 src::net 社区,实际: {names:?}"
325        );
326        assert!(
327            names.contains(&"src::http"),
328            "应检出 src::http 单文件社区,实际: {names:?}"
329        );
330        // 名字必须唯一(模块名 → wiki 产物文件名,重名互相覆盖)
331        let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
332        assert_eq!(unique.len(), names.len(), "模块名必须唯一: {names:?}");
333        // net 社区含 2 文件 + 2 实体(Contains 扩展)
334        let net = clusters.iter().find(|c| c.name == "src::net").unwrap();
335        assert_eq!(net.node_ids.len(), 4, "src::net 应含 2 文件 + 2 实体");
336    }
337}