Skip to main content

code_repo_wiki/analysis/
community.rs

1//! 文件级社区检测:用 leiden-rs(Leiden 算法,CPM 质量函数)在文件依赖图上
2//! 划分功能内聚的模块社区,替代旧的"目录前缀凝聚聚类"。
3//!
4//! 设计要点(对应演进计划 T1.2/T1.3):
5//! - 聚类单位是 **File 节点**,依赖边取跨文件的 Imports/Calls
6//!   (同文件内调用是内部实现细节,不参与模块划分);
7//! - 边权重与 graph.rs 构建时一致(Imports=0.8 / Calls=0.7),
8//!   同对文件间多条边按权重相加聚合;
9//! - CPM 质量函数 + 固定种子(确定性输出,可复现);
10//! - 无跨文件依赖时退化为"每文件一个社区"(Leiden 对无边图无划分意义);
11//! - 社区命名三档规则见 [`community_name`](T1.3):公共目录前缀 →
12//!   文件数最多目录 → module_{n},保证产物路径与断链检测的确定性。
13
14use std::collections::HashMap;
15
16use petgraph::visit::{EdgeRef, IntoEdgeReferences, IntoNodeReferences};
17
18use leiden_rs::{GraphDataBuilder, Leiden, LeidenConfig, QualityType};
19
20use crate::model::*;
21
22/// Leiden 固定种子:保证同一代码库多次运行产出相同社区划分
23const LEIDEN_SEED: u64 = 42;
24/// CPM 分辨率参数 γ:越高社区越碎。实测:γ=0.8 时单条跨文件调用边
25/// (权重 0.7)不满足合并增益,会把协作文件拆成独立社区(过碎);
26/// γ=0.5 时单边足够合并(0.7−0.5=0.2>0),多边强连接社区不受影响。
27/// 对代码图而言"任何跨文件调用都表示协作",取 0.5(演进计划 D4 实测调参)
28const LEIDEN_RESOLUTION: f64 = 0.5;
29
30/// 社区检测边权重(与 src/analysis/graph.rs 构建时的字面量同源)
31pub const WEIGHT_IMPORTS: f64 = 0.8;
32pub const WEIGHT_CALLS: f64 = 0.7;
33
34/// 文件所属目录键:反斜杠归一正斜杠、去尾分隔符、根目录文件归 "<root>"
35fn file_dir_key(graph: &KnowledgeGraph, nid: NodeId) -> String {
36    let path = graph
37        .graph
38        .node_weight(nid)
39        .and_then(|n| n.file_path.as_deref())
40        .unwrap_or("");
41    let norm = path.replace('\\', "/");
42    let dir = norm.rsplit_once('/').map(|(d, _)| d).unwrap_or(".");
43    let dir = dir.trim_end_matches('/');
44    if dir.is_empty() {
45        "<root>".to_string()
46    } else {
47        dir.to_string()
48    }
49}
50
51/// 文件级社区检测:返回 File 节点的社区划分(每社区一个 `Vec<NodeId>`)
52///
53/// 输出确定性:社区内按 file_path 字典序排序后作为分组键排序。
54pub fn detect_communities(graph: &KnowledgeGraph) -> Vec<Vec<NodeId>> {
55    detect_communities_with_resolution(graph, LEIDEN_RESOLUTION)
56}
57
58/// 带分辨率参数的社区检测(v13 D3 拆分:评测工具 gamma_scan 需要扫描
59/// 多个 γ 取值找最优粒度,生产默认走 detect_communities 的常量分辨率)
60pub fn detect_communities_with_resolution(graph: &KnowledgeGraph, resolution: f64) -> Vec<Vec<NodeId>> {
61    let file_nodes: Vec<NodeId> = graph
62        .graph
63        .node_references()
64        .filter(|(_, n)| n.kind == NodeKind::File)
65        .map(|(id, _)| id)
66        .collect();
67
68    if file_nodes.is_empty() {
69        return Vec::new();
70    }
71    if file_nodes.len() == 1 {
72        return vec![file_nodes];
73    }
74
75    // ===== 大仓库目录页分流(v26 方案 D 修正版)=====
76    // 目录超节点 + Leiden 的实测结论:CPM 合并增益按绝对边权重计算
77    // (期望量级=实体级单边 0.7~0.8),而超节点图的聚合边权重是跨
78    // 目录依赖条数之和(几十到几百),γ=0.5 下几乎任何聚合边都满足
79    // 合并条件 → 密集调用仓库全部并入一个社区(实测 99 文件仓库只剩
80    // 2 个模块页,信息丢失)。因此目录超节点图上的 Leiden 不可用。
81    // 修正:目录数 ≥ MIN_DIRS_FOR_SUPERNODE 的仓库直接以目录为社区
82    // (页面名=目录路径,新增/删除文件不改变划分,零随机参数);
83    // 中小仓库仍走实体级 Leiden(γ=0.5 已实测调优,粒度/成本最优)。
84    // 阈值 24 的论证(实机数据,v28 t10 复核):Unity 仓库 52 目录、
85    // code-repo-wiki 自身 15 目录(src+tests)属"大仓库"(目录页粒度合适);
86    // 150 文件测试 fixture(15 模块 × 10 文件 = 15 目录)以下走实体级
87    // Leiden。24 落在两档之间:≥24 时目录页规模可接受且免超节点图失真
88    // (v26 D 实测:密集调用仓库在目录超节点图上全部并入一个社区),
89    // <24 时实体级粒度更细(目录数少,页数可负担)。
90    const MIN_DIRS_FOR_SUPERNODE: usize = 24;
91    let mut dirs: std::collections::BTreeMap<String, Vec<NodeId>> = std::collections::BTreeMap::new();
92    for &nid in &file_nodes {
93        let d = file_dir_key(graph, nid);
94        dirs.entry(d).or_default().push(nid);
95    }
96    // 单目录退化保护(v29):目录数 ≤ 1(所有源文件平铺在同一目录,如
97    // src/ 平铺仓库;根目录散文件归 <root> 同此)时走实体级 Leiden 会把
98    // 整库聚成 1-2 个社区甚至每文件一社区,模块划分失去意义——直接走
99    // 目录页路径:单一社区 = 全部文件,模块名即目录名(community_name
100    // 公共目录前缀档),下游 api.md 的 `## src` 节与模块页引用均正常。
101    if dirs.len() <= 1 || dirs.len() >= MIN_DIRS_FOR_SUPERNODE {
102        return dirs.into_values().collect();
103    }
104
105    // File 节点 → 紧凑索引(leiden-rs 要求 0..n 连续,StableDiGraph 有洞)
106    let compact: HashMap<NodeId, usize> = file_nodes
107        .iter()
108        .enumerate()
109        .map(|(i, &nid)| (nid, i))
110        .collect();
111
112    // 实体 → 所属 File 映射(经 Contains 边反查)。
113    // 关键语义:Calls/Imports 边都挂在**实体节点**上(File 节点
114    // 只有 Contains 边),社区划分的单位是 File,必须先归位。
115    let mut entity_to_file: HashMap<NodeId, NodeId> = HashMap::new();
116    for edge in graph.graph.edge_references() {
117        let kind = graph.graph.edge_weight(edge.id()).map(|e| e.kind.clone());
118        if kind == Some(EdgeKind::Contains) && compact.contains_key(&edge.source()) {
119            entity_to_file.insert(edge.target(), edge.source());
120        }
121    }
122    // 端点归位:File 节点取自身,实体节点取其所属 File
123    let file_of = |nid: NodeId| -> Option<NodeId> {
124        if compact.contains_key(&nid) {
125            Some(nid)
126        } else {
127            entity_to_file.get(&nid).copied()
128        }
129    };
130
131    // 聚合跨文件依赖边:同对文件间多条边权重相加
132    let mut edge_weights: HashMap<(usize, usize), f64> = HashMap::new();
133    for edge in graph.graph.edge_references() {
134        // 结构安全证据(R1 审计):边权重由 build_graph 创建边时同步写入
135        //(StableDiGraph 的边权重不是 Option),此处仅做类型层面的取值;
136        // 若 graph 构造代码未来绕过权重初始化,本 expect 会立即失败暴露。
137        let e = graph
138            .graph
139            .edge_weight(edge.id())
140            .expect("边权重必然存在");
141        let w = match e.kind {
142            EdgeKind::Imports => WEIGHT_IMPORTS,
143            EdgeKind::Calls => WEIGHT_CALLS,
144            _ => continue, // 其他类型边(Contains/Implements)不参与社区划分
145        };
146        let (Some(sf), Some(tf)) = (file_of(edge.source()), file_of(edge.target())) else {
147            continue; // 端点既不是 File 也不是实体(Module 等中间节点)
148        };
149        let (Some(&si), Some(&ti)) = (compact.get(&sf), compact.get(&tf)) else {
150            continue;
151        };
152        if si == ti {
153            continue; // 同文件内依赖不构成模块间关系
154        }
155        *edge_weights.entry((si, ti)).or_insert(0.0) += w;
156    }
157
158    if edge_weights.is_empty() {
159        // 无任何跨文件依赖:Leiden 对无边图无划分意义,每文件自成社区
160        return file_nodes.into_iter().map(|nid| vec![nid]).collect();
161    }
162
163    // 构建 Leiden 输入图(有向、加权;add_edge 校验权重有限且 ≥0,不会失败)
164    // 结构安全证据(R1 审计):权重来源仅两处——常量 WEIGHT_IMPORTS/
165    // WEIGHT_CALLS(0.7/0.8,有限非负)与下方 edge_weights 的累加和;
166    // 不存在 NaN/Inf/负值注入路径,add_edge 的 Err 分支不可达。
167    let mut builder = GraphDataBuilder::new(file_nodes.len()).directed();
168    for ((s, t), w) in &edge_weights {
169        builder
170            .add_edge(*s, *t, *w)
171            .expect("边权重均为有限非负数");
172    }
173    let data = builder.build().expect("图数据构造失败");
174
175    let config = LeidenConfig {
176        quality: QualityType::CPM,
177        resolution,
178        seed: Some(LEIDEN_SEED),
179        ..Default::default()
180    };
181    // 结构安全证据(R1 审计):leiden-rs 0.8.1 对用户输入从不 panic——
182    // 空图/单节点/无边图均提前安全返回(本函数在 edge_weights 为空时
183    // 已提前返回单文件社区,此处输入恒为有效图);run() 仅返回
184    // internal-error 类 Result(图内部不变量破坏,不可由调用方触发)。
185    let result = Leiden::new(config)
186        .run(&data)
187        .expect("Leiden 社区检测失败");
188    let membership = result.partition.as_slice(); // membership[i] = 节点 i 的社区 ID
189
190    // 按社区 ID 分组回 File 节点
191    let mut groups: HashMap<usize, Vec<NodeId>> = HashMap::new();
192    for (i, &comm) in membership.iter().enumerate() {
193        groups.entry(comm).or_default().push(file_nodes[i]);
194    }
195
196    // 确定性输出:按社区大小降序(Graphify 稳定重索引:大社区编号优先,
197    // 新增小社区不改变既有大社区的相对序,产物文件名/模块编号跨次稳定),
198    // 同大小按最小 file_path 升序(全序确定,不依赖哈希表遍历序)
199    let mut communities: Vec<Vec<NodeId>> = groups.into_values().collect();
200    communities.sort_by(|a, b| {
201        b.len()
202            .cmp(&a.len())
203            .then_with(|| min_file_path(graph, a).cmp(&min_file_path(graph, b)))
204    });
205    communities
206}
207
208/// 社区内最小 file_path(确定性排序键;无路径节点视为空串排最前)
209fn min_file_path(graph: &KnowledgeGraph, files: &[NodeId]) -> String {
210    files
211        .iter()
212        .filter_map(|nid| graph.graph.node_weight(*nid).and_then(|n| n.file_path.clone()))
213        .min()
214        .unwrap_or_default()
215}
216
217/// 社区命名三档规则(T1.3):
218///
219/// 1. **公共目录前缀**:所有文件父目录的最长公共目录段 → `join("::")`
220///    (最贴近"目录=模块"的直觉,且模块名→产物文件名/断链检测全部稳定);
221/// 2. **文件数最多目录**:公共前缀为空(不同根)时,按父目录分组取文件数
222///    最多的目录名;
223/// 3. **module_{n}**:仍为空(如根目录散文件)时按序号回退。
224///
225/// 纯函数:仅依赖文件路径,不依赖图结构,可独立单测。
226pub fn community_name(files: &[String], fallback_index: usize) -> String {
227    if files.is_empty() {
228        return format!("module_{fallback_index}");
229    }
230
231    let dirs: Vec<Vec<String>> = files.iter().map(|p| dir_segments(p)).collect();
232
233    // 档 1:所有文件父目录的最长公共目录段
234    let min_len = dirs.iter().map(|d| d.len()).min().unwrap_or(0);
235    let mut common = 0usize;
236    'outer: for i in 0..min_len {
237        let seg = &dirs[0][i];
238        for other in &dirs[1..] {
239            if other.get(i) != Some(seg) {
240                break 'outer;
241            }
242        }
243        common = i + 1;
244    }
245    if common > 0 {
246        return dirs[0][..common].join("::");
247    }
248
249    // 档 2:文件数最多的父目录(根目录散文件用占位键,最终落档 3)
250    let mut dir_counts: HashMap<String, usize> = HashMap::new();
251    for d in &dirs {
252        let key = if d.is_empty() {
253            "<root>".to_string()
254        } else {
255            d.join("::")
256        };
257        *dir_counts.entry(key).or_insert(0) += 1;
258    }
259    if let Some((best, _)) = dir_counts
260        .into_iter()
261        .max_by_key(|(name, count)| (*count, name.clone()))
262        && best != "<root>"
263    {
264        return best;
265    }
266
267    // 档 3:确定性回退
268    format!("module_{fallback_index}")
269}
270
271/// 提取路径的父目录段(不含文件名与盘符 Prefix,Windows 安全)
272fn dir_segments(path: &str) -> Vec<String> {
273    use std::path::Component;
274    std::path::Path::new(path)
275        .parent()
276        .map(|p| {
277            p.components()
278                .filter_map(|c| match c {
279                    Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
280                    _ => None,
281                })
282                .collect()
283        })
284        .unwrap_or_default()
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    /// 构造一个小型知识图谱:src/a.rs、src/b.rs 跨文件调用,src/net/tcp.rs 独立
292    fn make_graph() -> KnowledgeGraph {
293        let mut kg = KnowledgeGraph::default();
294        let g = &mut kg.graph;
295        let add_file =
296            |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
297             path: &str|
298             -> (NodeId, NodeId) {
299                let module_path: Vec<String> = dir_segments(path);
300                let nid = g.add_node(CodeNode {
301                    id: NodeId::new(g.node_count()),
302                    kind: NodeKind::File,
303                    name: path.into(),
304                    file_path: Some(path.into()),
305                    line_range: None,
306                    doc_comment: None,
307                    signature: None, visibility: None,
308                    module_path,
309                });
310                // File → Entity 的 Contains 边
311                let eid = g.add_node(CodeNode {
312                    id: NodeId::new(g.node_count()),
313                    kind: NodeKind::Function,
314                    name: format!("f{}", nid.index()),
315                    file_path: Some(path.into()),
316                    line_range: None,
317                    doc_comment: None,
318                    signature: None, visibility: None,
319                    module_path: Vec::new(),
320                });
321                g.add_edge(
322                    nid,
323                    eid,
324                    CodeEdge {
325                        id: EdgeId::new(g.edge_count()),
326                        kind: EdgeKind::Contains,
327                        source: nid,
328                        target: eid,
329                        weight: 1.0,
330                        location: None,
331                    },
332                );
333                (nid, eid)
334            };
335        let (_a, ea) = add_file(g, "src/a.rs");
336        let (_b, eb) = add_file(g, "src/b.rs");
337        let _tcp = add_file(g, "src/net/tcp.rs");
338        // a 的实体 → b 的实体(跨文件 Calls)
339        g.add_edge(
340            ea,
341            eb,
342            CodeEdge {
343                id: EdgeId::new(g.edge_count()),
344                kind: EdgeKind::Calls,
345                source: ea,
346                target: eb,
347                weight: 0.7,
348                location: None,
349            },
350        );
351        kg
352    }
353
354    #[test]
355    fn test_detect_communities_basic() {
356        let kg = make_graph();
357        let communities = detect_communities(&kg);
358        // a/b 有跨文件调用 → 同社区;tcp.rs 无边 → 自成社区
359        assert_eq!(communities.len(), 2, "应产出 2 个社区");
360        let ab = communities
361            .iter()
362            .find(|c| c.len() == 2)
363            .expect("应存在含 2 文件的社区");
364        let paths: Vec<String> = ab
365            .iter()
366            .map(|nid| kg.graph.node_weight(*nid).unwrap().file_path.clone().unwrap())
367            .collect();
368        assert!(paths.contains(&"src/a.rs".to_string()));
369        assert!(paths.contains(&"src/b.rs".to_string()));
370    }
371
372    #[test]
373    fn test_detect_communities_empty_graph() {
374        let kg = KnowledgeGraph::default();
375        assert!(detect_communities(&kg).is_empty());
376    }
377
378    #[test]
379    fn test_detect_communities_single_file() {
380        let mut kg = KnowledgeGraph::default();
381        let g = &mut kg.graph;
382        g.add_node(CodeNode {
383            id: NodeId::new(0),
384            kind: NodeKind::File,
385            name: "src/main.rs".into(),
386            file_path: Some("src/main.rs".into()),
387            line_range: None,
388            doc_comment: None,
389            signature: None, visibility: None,
390            module_path: vec!["src".into()],
391        });
392        let communities = detect_communities(&kg);
393        assert_eq!(communities.len(), 1);
394        assert_eq!(communities[0].len(), 1);
395    }
396
397    #[test]
398    fn test_community_name_common_prefix() {
399        let files = vec!["src/net/tcp.rs".to_string(), "src/net/udp.rs".to_string()];
400        assert_eq!(community_name(&files, 0), "src::net");
401    }
402
403    #[test]
404    fn test_community_name_single_file() {
405        let files = vec!["src/config.rs".to_string()];
406        assert_eq!(community_name(&files, 3), "src");
407    }
408
409    #[test]
410    fn test_community_name_most_populated_dir() {
411        // 无公共前缀(不同根):按文件数最多目录回退
412        let files = vec![
413            "app/main.rs".to_string(),
414            "app/util.rs".to_string(),
415            "lib/helper.rs".to_string(),
416        ];
417        assert_eq!(community_name(&files, 1), "app");
418    }
419
420    #[test]
421    fn test_community_name_fallback() {
422        let files = vec!["main.rs".to_string()];
423        assert_eq!(community_name(&files, 7), "module_7");
424        assert_eq!(community_name(&[], 2), "module_2");
425    }
426
427    /// 稳定重排序(t07):社区按大小降序编号——新增小社区不改变
428    /// 既有大社区的相对序,模块编号/产物文件名跨次稳定
429    #[test]
430    fn test_detect_communities_stable_order() {
431        let mut kg = KnowledgeGraph::default();
432        let g = &mut kg.graph;
433        // 大社区:src/net/ 下 2 文件 + 2 实体(4 节点)
434        let add_comm = |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
435                            path: &str| {
436            let nid = g.add_node(CodeNode {
437                id: NodeId::new(g.node_count()),
438                kind: NodeKind::File,
439                name: path.into(),
440                file_path: Some(path.into()),
441                line_range: None,
442                doc_comment: None,
443                signature: None, visibility: None,
444                module_path: dir_segments(path),
445            });
446            let eid = g.add_node(CodeNode {
447                id: NodeId::new(g.node_count()),
448                kind: NodeKind::Function,
449                name: format!("f{}", nid.index()),
450                file_path: Some(path.into()),
451                line_range: None,
452                doc_comment: None,
453                signature: None, visibility: None,
454                module_path: Vec::new(),
455            });
456            g.add_edge(
457                nid,
458                eid,
459                CodeEdge {
460                    id: EdgeId::new(g.edge_count()),
461                    kind: EdgeKind::Contains,
462                    source: nid,
463                    target: eid,
464                    weight: 1.0,
465                    location: None,
466                },
467            );
468        };
469        add_comm(g, "src/net/tcp.rs");
470        add_comm(g, "src/net/udp.rs");
471        // 小社区:孤立单文件
472        add_comm(g, "src/util.rs");
473        // 定位 tcp/udp 的 File 节点
474        let f = |g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>, path: &str| {
475            g.node_indices()
476                .find(|n| g.node_weight(*n).map(|n| n.name == path).unwrap_or(false))
477                .unwrap()
478        };
479        let _tcp = f(g, "src/net/tcp.rs");
480        let _udp = f(g, "src/net/udp.rs");
481        // tcp 的实体 → udp 的实体(Calls 边促成同社区)
482        let fns: Vec<_> = g
483            .node_indices()
484            .filter(|n| g.node_weight(*n).map(|n| n.kind == NodeKind::Function).unwrap_or(false))
485            .collect();
486        g.add_edge(
487            fns[0],
488            fns[1],
489            CodeEdge {
490                id: EdgeId::new(g.edge_count()),
491                kind: EdgeKind::Calls,
492                source: fns[0],
493                target: fns[1],
494                weight: 0.7,
495                location: None,
496            },
497        );
498        let communities = detect_communities(&kg);
499        assert_eq!(communities.len(), 2);
500        // 大社区(2 文件)必须排在单文件社区之前(大小降序)
501        assert_eq!(communities[0].len(), 2, "大社区应排前(稳定重排序)");
502        assert_eq!(communities[1].len(), 1);
503    }
504
505    /// v28 t10:目录阈值分流测试的合成图构造器——
506    /// n_dirs 个目录 dirNN/,每目录 files_per_dir 个文件(各带 1 个实体),
507    /// connected_pairs 指定跨目录 Calls 边(dir_a 的实体 0 → dir_b 的实体 0,
508    /// 每目录至少 1 条边可被引用)。
509    fn make_dirs_graph(
510        n_dirs: usize,
511        files_per_dir: usize,
512        connected_pairs: &[(usize, usize)],
513    ) -> KnowledgeGraph {
514        let mut kg = KnowledgeGraph::default();
515        let g = &mut kg.graph;
516        // 目录 → 该目录第一个文件实体的节点(供跨目录边引用)
517        let mut first_entity: HashMap<String, NodeId> = HashMap::new();
518        for d in 0..n_dirs {
519            let dir = format!("dir{d:02}");
520            for f in 0..files_per_dir {
521                let path = format!("{dir}/f{f}.rs");
522                let nid = g.add_node(CodeNode {
523                    id: NodeId::new(g.node_count()),
524                    kind: NodeKind::File,
525                    name: path.clone(),
526                    file_path: Some(path.clone()),
527                    line_range: None,
528                    doc_comment: None,
529                    signature: None,
530                    visibility: None,
531                    module_path: dir_segments(&path),
532                });
533                let eid = g.add_node(CodeNode {
534                    id: NodeId::new(g.node_count()),
535                    kind: NodeKind::Function,
536                    name: format!("f{f}"),
537                    file_path: Some(path),
538                    line_range: None,
539                    doc_comment: None,
540                    signature: None,
541                    visibility: None,
542                    module_path: Vec::new(),
543                });
544                g.add_edge(
545                    nid,
546                    eid,
547                    CodeEdge {
548                        id: EdgeId::new(g.edge_count()),
549                        kind: EdgeKind::Contains,
550                        source: nid,
551                        target: eid,
552                        weight: 1.0,
553                        location: None,
554                    },
555                );
556                first_entity.entry(dir.clone()).or_insert(eid);
557            }
558        }
559        for (a, b) in connected_pairs {
560            let ea = first_entity[&format!("dir{a:02}")];
561            let eb = first_entity[&format!("dir{b:02}")];
562            g.add_edge(
563                ea,
564                eb,
565                CodeEdge {
566                    id: EdgeId::new(g.edge_count()),
567                    kind: EdgeKind::Calls,
568                    source: ea,
569                    target: eb,
570                    weight: 0.7,
571                    location: None,
572                },
573            );
574        }
575        kg
576    }
577
578    /// v28 t10:目录阈值分流——20 目录(< 24)走实体级 Leiden:
579    /// 跨目录调用把连接链上的文件合并成混合目录社区(社区数 > 目录数,
580    /// 因为无边的独立文件每文件一社区),且两次调用结果完全一致
581    /// (输出确定性,固定种子)。
582    #[test]
583    fn test_detect_communities_entity_level_below_threshold() {
584        // 20 目录 × 2 文件;三组跨目录链连接(0-1-2、3-4),其余目录独立
585        let kg = make_dirs_graph(20, 2, &[(0, 1), (1, 2), (3, 4)]);
586
587        let first = detect_communities(&kg);
588        let second = detect_communities(&kg);
589        assert_eq!(first, second, "同图两次划分必须完全一致(确定性)");
590
591        assert!(
592            first.len() > 20,
593            "实体级划分社区数应多于目录数(独立目录每文件一社区), 实际 {} 个社区",
594            first.len()
595        );
596        // 存在跨目录混合社区:连接链上的文件被 Leiden 合并
597        let mixed = first.iter().any(|c| {
598            let dirs_in: std::collections::HashSet<&str> = c
599                .iter()
600                .filter_map(|nid| kg.graph.node_weight(*nid).and_then(|n| n.file_path.as_deref()))
601                .filter_map(|p| p.rsplit_once('/').map(|(d, _)| d))
602                .collect();
603            dirs_in.len() > 1
604        });
605        assert!(mixed, "跨目录调用链应产生混合目录社区, 实际: {:?}", first);
606    }
607
608    /// v28 t10:目录阈值分流——24/30/40 目录(≥ 24)走目录级:
609    /// 社区数 == 目录数,每社区恰为该目录的完整文件集;即使每对目录间
610    /// 都有跨目录依赖边也不合并(目录级零随机参数,新增/删除文件不改
611    /// 变划分);两次调用一致(确定性)。
612    #[test]
613    fn test_detect_communities_dir_level_at_and_above_threshold() {
614        // 24 目录 × 3 文件 / 30 目录 × 2 文件 / 40 目录 × 3 文件
615        for (n_dirs, files_per_dir) in [(24usize, 3usize), (30, 2), (40, 3)] {
616            // 全链跨目录边(最强制合并压力:目录级分流下必须仍按目录划分)
617            let pairs: Vec<(usize, usize)> = (0..n_dirs - 1).map(|a| (a, a + 1)).collect();
618            let kg = make_dirs_graph(n_dirs, files_per_dir, &pairs);
619
620            let first = detect_communities(&kg);
621            let second = detect_communities(&kg);
622            assert_eq!(first, second, "{n_dirs} 目录两次划分必须一致(确定性)");
623            assert_eq!(
624                first.len(),
625                n_dirs,
626                "{n_dirs} 目录应产出 {n_dirs} 个社区, 实际 {}",
627                first.len()
628            );
629
630            for comm in &first {
631                let mut paths: Vec<String> = comm
632                    .iter()
633                    .filter_map(|nid| kg.graph.node_weight(*nid).and_then(|n| n.file_path.clone()))
634                    .collect();
635                paths.sort();
636                assert_eq!(paths.len(), files_per_dir, "每社区应为单目录全部文件: {paths:?}");
637                let dirs_in: std::collections::HashSet<&str> = paths
638                    .iter()
639                    .filter_map(|p| p.rsplit_once('/').map(|(d, _)| d))
640                    .collect();
641                assert_eq!(dirs_in.len(), 1, "社区内文件必须同属一个目录: {paths:?}");
642            }
643        }
644    }
645
646    /// v29:单目录仓库退化保护——目录数 == 1(平铺仓库,全部文件同一目录)
647    /// 时直接走目录页路径:整库产出 1 个社区且包含全部文件(修复前走实体级
648    /// Leiden:无跨文件边时每文件一社区、有边时整库聚成 1-2 个社区,模块
649    /// 划分失去意义);两次调用一致(确定性)。根目录散文件(<root> 键)
650    /// 同此处理。
651    #[test]
652    fn test_detect_communities_single_dir_repo() {
653        // 10 个文件全在 dir00/,无跨文件边(修复前实体级 Leiden → 10 社区)
654        let kg = make_dirs_graph(1, 10, &[]);
655        let first = detect_communities(&kg);
656        let second = detect_communities(&kg);
657        assert_eq!(first, second, "单目录仓库两次划分必须一致(确定性)");
658        assert_eq!(
659            first.len(),
660            1,
661            "单目录仓库应产出 1 个社区(整库一个模块), 实际 {} 个",
662            first.len()
663        );
664        assert_eq!(first[0].len(), 10, "社区应包含全部 10 个文件");
665        let all_in_dir00 = first[0].iter().all(|nid| {
666            kg.graph
667                .node_weight(*nid)
668                .and_then(|n| n.file_path.as_deref())
669                .is_some_and(|p| p.starts_with("dir00/"))
670        });
671        assert!(all_in_dir00, "社区内所有文件必须同属唯一目录");
672
673        // 根目录散文件(file_dir_key 归 <root>)同样整体一社区
674        let mut kg2 = KnowledgeGraph::default();
675        let g = &mut kg2.graph;
676        for i in 0..5 {
677            let path = format!("main{i}.rs");
678            let nid = g.add_node(CodeNode {
679                id: NodeId::new(g.node_count()),
680                kind: NodeKind::File,
681                name: path.clone(),
682                file_path: Some(path),
683                line_range: None,
684                doc_comment: None,
685                signature: None,
686                visibility: None,
687                module_path: Vec::new(),
688            });
689            let eid = g.add_node(CodeNode {
690                id: NodeId::new(g.node_count()),
691                kind: NodeKind::Function,
692                name: format!("f{i}"),
693                file_path: None,
694                line_range: None,
695                doc_comment: None,
696                signature: None,
697                visibility: None,
698                module_path: Vec::new(),
699            });
700            g.add_edge(
701                nid,
702                eid,
703                CodeEdge {
704                    id: EdgeId::new(g.edge_count()),
705                    kind: EdgeKind::Contains,
706                    source: nid,
707                    target: eid,
708                    weight: 1.0,
709                    location: None,
710                },
711            );
712        }
713        let comms = detect_communities(&kg2);
714        assert_eq!(comms.len(), 1, "根目录散文件仓库也应整体一社区");
715        assert_eq!(comms[0].len(), 5);
716    }
717}