Skip to main content

code_repo_wiki/generate/
wiki.rs

1use std::path::Path;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use anyhow::Result;
5
6use crate::config::schema::WikiConfig;
7use crate::generate::chunk::Chunk;
8use crate::generate::llm::{LlmProvider, Message};
9use crate::generate::prompt;
10use crate::generate::GenerationOutput;
11use crate::model::{DocumentKind, EdgeKind, KnowledgeGraph, NodeId, Reference, WikiDocument};
12
13/// Wiki 页面生成器
14///
15/// 通过 LLM 为每个模块生成叙述性的 Wiki 页面,
16/// 供人类开发者阅读和理解代码。
17pub struct WikiGenerator<'a, P: LlmProvider> {
18    provider: &'a P,
19    call_count: AtomicUsize,
20    /// 生成失败的模块名列表(演进计划 T3.2 失败隔离:失败只记录不中断)
21    failed: std::sync::Mutex<Vec<String>>,
22    /// describe_modules 并发信号量(演进计划 T5.1:模块职责描述并行
23    /// 生成时限制并发,避免 10+ 模块仓库一次性打爆 LLM API 限流)
24    semaphore: std::sync::Arc<tokio::sync::Semaphore>,
25    /// 模块职责描述缓存(v31 C-02):内存 memo + 落盘缓存。
26    /// 架构概览与项目概览同一轮生成会各调一次 describe_modules——
27    /// 内存 memo 消除同一轮内的重复 LLM 调用;落盘缓存按模块内容
28    /// 指纹(涉及源文件 SHA256)跨进程复用,增量更新(watch 频繁
29    /// 触发)不再对未变模块重复调用 LLM。锁内只做短同步操作
30    /// (查/写 HashMap),绝不在持有锁时 await。
31    desc_cache: std::sync::Mutex<Option<ModuleDescCache>>,
32    /// HEAD 短哈希缓存(v32 10.2):每轮生成只开一次 git 仓库。
33    /// None 也可能被缓存(非 git 仓库——生成期间不会变成 git 仓库)。
34    head_short: std::sync::OnceLock<Option<String>>,
35}
36
37impl<P: LlmProvider> WikiGenerator<'_, P> {
38    /// HEAD 短哈希(每轮生成首次调用计算一次,此后复用)
39    fn head_short_for(&self, root: &crate::project::ProjectRoot) -> Option<String> {
40        self.head_short
41            .get_or_init(|| git_head_short(root))
42            .clone()
43    }
44}
45
46/// 模块职责描述缓存条目
47///
48/// fingerprint = 模块涉及源文件的联合内容指纹(见 module_files_fingerprint);
49/// 文件内容未变则指纹一致,描述可直接复用(不触发 LLM 调用)。
50#[derive(serde::Serialize, serde::Deserialize)]
51struct CacheEntry {
52    fingerprint: String,
53    description: String,
54}
55
56    /// 模块职责描述缓存(v31 C-02)
57    ///
58    /// 落盘位置 `{output_dir}/.state/module_descriptions.json`(与
59    /// generation_state.json 同目录)。加载损坏/缺失时返回空缓存并告警——
60    /// 描述按需回退 LLM 重新生成,缓存故障绝不阻断主流程。
61    struct ModuleDescCache {
62        entries: std::collections::HashMap<String, CacheEntry>,
63    }
64
65    /// 读取仓库 HEAD 短哈希(v32 10.2 页面基线行)
66    ///
67    /// git2 打开失败(非 git 仓库/无 HEAD/权限)一律返回 None——基线行
68    /// 是附加信息,任何 git 读取问题都不应中断生成。短哈希取前 8 位,
69    /// 与 `git log --oneline` 的默认缩写一致,足够人工核对版本。
70    fn git_head_short(root: &crate::project::ProjectRoot) -> Option<String> {
71        let repo = git2::Repository::open(root.path()).ok()?;
72        let head = repo.head().ok()?;
73        let commit = head.peel_to_commit().ok()?;
74        let id = commit.id().to_string();
75        Some(id[..id.len().min(8)].to_string())
76    }
77
78impl ModuleDescCache {
79    fn new() -> Self {
80        Self {
81            entries: std::collections::HashMap::new(),
82        }
83    }
84
85    /// 从磁盘加载(损坏/缺失→空缓存,调用方决定是否告警)
86    fn load(path: &Path) -> Self {
87        let Ok(content) = std::fs::read_to_string(path) else {
88            return Self::new();
89        };
90        match serde_json::from_str::<std::collections::HashMap<String, CacheEntry>>(&content) {
91            Ok(entries) => Self { entries },
92            Err(e) => {
93                tracing::warn!(
94                    "模块描述缓存解析失败(回退空缓存,按需重新生成): {} {}",
95                    path.display(),
96                    e
97                );
98                Self::new()
99            }
100        }
101    }
102
103    /// 原子写盘(temp + rename);失败仅告警不阻断
104    fn save(&self, path: &Path) {
105        let Ok(content) = serde_json::to_string(&self.entries) else {
106            return;
107        };
108        let Some(parent) = path.parent() else {
109            return;
110        };
111        let _ = std::fs::create_dir_all(parent);
112        let tmp = path.with_extension("json.tmp");
113        if std::fs::write(&tmp, content).is_ok() {
114            let _ = std::fs::rename(&tmp, path);
115        }
116    }
117}
118
119/// 计算模块涉及源文件的联合内容指纹
120///
121/// 取模块全部实体节点所属文件(去重排序),逐个计算 SHA256 后拼接——
122/// 任一文件内容变化都会改变指纹,使该模块的描述缓存失效。
123/// 文件不存在/不可读时以 "missing" 占位(该文件变更=指纹变化=重新描述)。
124fn module_files_fingerprint(
125    module: &crate::model::ModuleCluster,
126    graph: &KnowledgeGraph,
127    root: &crate::project::ProjectRoot,
128) -> String {
129    let mut files: Vec<&str> = module
130        .node_ids
131        .iter()
132        .filter_map(|nid| graph.graph.node_weight(*nid))
133        .filter_map(|n| n.file_path.as_deref())
134        .collect();
135    files.sort_unstable();
136    files.dedup();
137    files
138        .iter()
139        .map(|f| {
140            // 指纹基准=项目根(与 incremental::state 的 file_fingerprints 同源:
141            // state.rs 以 root.path().join(insight.path) 记录源文件指纹)——
142            // 源文件在根下,产物在根/.code-repo-wiki 下,二者必须区分
143            crate::incremental::state::GenerationState::compute_file_fingerprint(
144                &root.path().join(f),
145            )
146            .unwrap_or_else(|_| "missing".to_string())
147        })
148        .collect::<Vec<_>>()
149        .join("|")
150}
151
152/// 引用契约重试上限(P0-1):生成后校验源码引用,无效时重试注入
153/// 反馈,最多重试 CITATION_RETRY_MAX 次(共 CITATION_RETRY_MAX + 1 次
154/// 调用)。"总是重试"但必须有上限防死循环(每次重试消耗 LLM 调用)。
155pub const CITATION_RETRY_MAX: usize = 2;
156
157/// Mermaid 语法校验重试上限(G2):与引用契约对齐——首次调用 + 每次
158/// 坏块反馈后重试,共 `MERMAID_RETRY_MAX + 1` 次调用;耗尽后降级
159/// (坏块转 text + 标记注释,见 output::mermaid_check::degrade_mermaid_blocks)。
160/// 与 CITATION_RETRY_MAX 合并进同一重试循环(上限取两者最大值)。
161pub const MERMAID_RETRY_MAX: usize = crate::output::mermaid_check::MERMAID_RETRY_MAX;
162
163/// 调用级失败重试上限(v22 修复):瞬时网络错误(连接重置/超时/5xx)
164/// 重试最多 CALL_RETRY_MAX 次(含首次),间隔 500ms/1000ms 指数退避。
165/// 所有错误统一按可重试处理:持久错误(参数/鉴权 4xx)最多多等 1.5s
166/// 后仍原样返回给外层失败隔离——不吞错、不掩盖,与「重试语义只补偿
167/// 瞬时故障」的边界一致;校验类失败不在此处处理(走既有反馈循环)。
168const CALL_RETRY_MAX: usize = 3;
169
170/// 带退避的 LLM 调用重试
171///
172/// provider 泛型化(Mock 也走此路径):mock/测试注入的失败因此最多
173/// 慢 1.5s,换来真实 provider 的瞬时错误自愈——长流水线中每模块一次
174/// 重试比整轮重跑便宜两个数量级。
175async fn complete_with_retry<P: LlmProvider>(
176    provider: &P,
177    messages: &[Message],
178    module: &str,
179) -> Result<String> {
180    let mut last_err = None;
181    for attempt in 0..CALL_RETRY_MAX {
182        match provider.complete(messages).await {
183            Ok(content) => return Ok(content),
184            Err(e) => {
185                tracing::warn!(
186                    "LLM 调用失败(第 {} 次重试): {} {}",
187                    attempt + 1,
188                    module,
189                    e
190                );
191                last_err = Some(e);
192                tokio::time::sleep(std::time::Duration::from_millis(500 << attempt)).await;
193            }
194        }
195    }
196    Err(last_err.expect("CALL_RETRY_MAX 至少为 1"))
197}
198
199impl<'a, P: LlmProvider> WikiGenerator<'a, P> {
200    /// 使用指定的 LLM Provider 创建 WikiGenerator
201    ///
202    /// max_concurrent 控制 describe_modules 的并行上限(0 表示不限制)。
203    pub fn new(provider: &'a P, max_concurrent: usize) -> Self {
204        // tokio Semaphore 许可数有 MAX_PERMITS 上限(约 2^61),usize::MAX 会 panic;
205        // "0=不限制" 用足够大的许可数表达(对真实并发规模永不构成瓶颈)
206        let max = if max_concurrent == 0 { 1_000_000_000 } else { max_concurrent };
207        Self {
208            provider,
209            call_count: AtomicUsize::new(0),
210            failed: std::sync::Mutex::new(Vec::new()),
211            semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(max)),
212            // 缓存懒加载:首次 describe_modules 调用时按需读盘
213            desc_cache: std::sync::Mutex::new(None),
214            // HEAD 短哈希懒加载:首次页面构造时计算(非 git 仓库缓存 None)
215            head_short: std::sync::OnceLock::new(),
216        }
217    }
218
219    /// 返回已完成的 LLM 调用次数
220    pub fn llm_call_count(&self) -> usize {
221        self.call_count.load(Ordering::Relaxed)
222    }
223
224    /// 返回生成失败的模块名列表(演进计划 T3.2:失败隔离的可见性出口)
225    pub fn failed_modules(&self) -> Vec<String> {
226        self.failed.lock().map(|g| g.clone()).unwrap_or_default()
227    }
228
229    /// 记录一个模块的生成失败(内部使用,并发安全)
230    pub(crate) fn record_failure(&self, module: String) {
231        if let Ok(mut failed) = self.failed.lock() {
232            failed.push(module);
233        }
234    }
235
236    /// 生成模块 Wiki 页面
237    ///
238    /// * `chunk` — 模块的代码数据块
239    /// * `card_summary` — 之前生成的 Knowledge Card 摘要,作为上下文参考
240    /// * `config` — Wiki 配置(用于获取语言设置等)
241    ///
242    /// P0-1 引用契约:生成后校验源码引用(文件存在 + 行号有效),
243    /// 引用无效时重试(最多 `CITATION_RETRY_MAX` 次,用户决策"总是重试")。
244    /// 重试注入错误反馈(无效引用清单),最后一次仍失败时返回错误——
245    /// 由调用方失败隔离(record_failure)记录,不产出无引用的页面。
246    /// G2 Mermaid 契约:正文中的 Mermaid 代码块同样校验(merman 权威解析),
247    /// 坏块错误消息注入重试反馈;重试耗尽后**降级**而非失败——坏块替换为
248    /// text fence + 标记注释(OpenWiki degrade-and-repair),页面照常产出。
249    pub async fn generate_wiki_page(
250        &self,
251        chunk: &Chunk,
252        card_summary: &str,
253        config: &WikiConfig,
254        root: &crate::project::ProjectRoot,
255        entity_ranges: Option<&crate::output::citation::EntityRanges>,
256    ) -> Result<WikiDocument> {
257        if chunk.is_empty() {
258            anyhow::bail!("空块,跳过 Wiki 页面生成");
259        }
260
261        let language = &config.wiki.language;
262        let mut messages =
263            prompt::wiki_page_prompt(chunk, card_summary, language, &config.wiki.guide.notes);
264        let mut content = String::new();
265        let mut last_invalid = Vec::new();
266        let mut last_mermaid = Vec::new();
267
268        // 重试循环:首次调用 + 每次校验失败后追加反馈重试(共 RETRY_MAX + 1 次调用)。
269        // 引用与 Mermaid 校验共享同一循环(上限取两者最大值——当前均为 2),
270        // 每次调用后两类校验都执行,任一失败都注入对应反馈。
271        let retry_max = CITATION_RETRY_MAX.max(MERMAID_RETRY_MAX);
272        for attempt in 0..=retry_max {
273            self.call_count.fetch_add(1, Ordering::Relaxed);
274            // v22 修复:调用级失败(连接重置/超时/5xx 等瞬时错误)原先
275            // 直接 `?` 抛出——长任务中瞬时错误会让整个模块页静默丢失
276            // (Unity 实测 10 个模块页因调用失败而缺失,卡片页一并丢失)。
277            // 此处只重试调用错误;校验失败走下方既有反馈循环。
278            content = complete_with_retry(self.provider, &messages, &chunk.module_path.join("::"))
279                .await?;
280            // 空/纯空白内容同样视为校验失败(重试语义:不产出空白页面)
281            last_invalid = if content.trim().is_empty() {
282                vec![crate::output::citation::InvalidCitation {
283                    citation: crate::output::citation::Citation {
284                        path: String::new(),
285                        start: 0,
286                        end: 0,
287                    },
288                    reason: "输出为空(未生成任何内容)".into(),
289                }]
290            } else {
291                // v14 B 组(t03 拍板):生产路径传实体行区间表 → 两级校验
292                // (文件级 + 区间重叠);None(测试/无表场景)退化为文件级。
293                match entity_ranges {
294                    Some(ranges) => {
295                        crate::output::citation::validate_citations_against_entities(
296                            root.path(),
297                            &content,
298                            ranges,
299                        )
300                    }
301                    None => crate::output::citation::validate_citations(root.path(), &content),
302                }
303            };
304            last_mermaid = crate::output::mermaid_check::validate_mermaid_blocks(&content);
305            if last_invalid.is_empty() && last_mermaid.is_empty() {
306                break;
307            }
308            if !last_invalid.is_empty() {
309                tracing::warn!(
310                    "Wiki 页面引用校验失败(第 {} 次,无效 {} 条): {}",
311                    attempt + 1,
312                    last_invalid.len(),
313                    chunk.module_path.join("::")
314                );
315            }
316            if !last_mermaid.is_empty() {
317                tracing::warn!(
318                    "Wiki 页面 Mermaid 校验失败(第 {} 次,坏块 {} 个): {}",
319                    attempt + 1,
320                    last_mermaid.len(),
321                    chunk.module_path.join("::")
322                );
323            }
324            // 重试机会已用完:跳出循环走收尾路径
325            if attempt == retry_max {
326                break;
327            }
328            if !last_invalid.is_empty() {
329                messages.push(Message::user(
330                    crate::output::citation::retry_feedback(&last_invalid),
331                ));
332            }
333            if !last_mermaid.is_empty() {
334                messages.push(Message::user(
335                    crate::output::mermaid_check::mermaid_retry_feedback(&last_mermaid),
336                ));
337            }
338        }
339
340        // 引用校验:重试耗尽仍无效 → 失败(不产出无引用的页面)
341        if !last_invalid.is_empty() {
342            anyhow::bail!(
343                "Wiki 页面引用校验失败(重试 {} 次仍无效,共 {} 条无效引用): {}",
344                retry_max,
345                last_invalid.len(),
346                chunk.module_path.join("::")
347            );
348        }
349        // Mermaid 校验:重试耗尽仍坏 → 降级(坏块转 text + 注释),页面保留。
350        // 降级注释含错误消息,供人工与下次 LLM 生成时参考修复(repair 语义)。
351        if !last_mermaid.is_empty() {
352            tracing::warn!(
353                "Wiki 页面 Mermaid 重试耗尽({} 个坏块),降级为 text 块: {}",
354                last_mermaid.len(),
355                chunk.module_path.join("::")
356            );
357            content = crate::output::mermaid_check::degrade_mermaid_blocks(&content, &last_mermaid);
358        }
359
360        let now = chrono::Utc::now().to_rfc3339();
361        // v32 10.2:页面基线行——HEAD 短哈希(非 git 仓库为 None 省略)。
362        // 每轮生成只计算一次(OnceCell 缓存),模块页循环内不重复开仓库。
363        let based_on_commit = self.head_short_for(root);
364
365        Ok(WikiDocument {
366            // 标题 = 完整模块路径("src::generate"):crossref 校验与概览/架构
367            // 页引用的 target_title(模块名)一致,且链接文本比末段更可辨识;
368            // 页面文件名由 module_path 派生,与标题解耦
369            title: chunk.module_path.join("::"),
370            kind: DocumentKind::WikiPage,
371            content,
372            language: config.wiki.language.clone(),
373            module_path: chunk.module_path.clone(),
374            references: build_references(chunk, &config.wiki.language),
375            last_updated: now,
376            based_on_commit,
377            fingerprint: None,
378        })
379    }
380
381    /// 带 Mermaid 校验的 LLM 调用(G2):架构/概览等全局文档的专用路径
382    ///
383    /// 这些页面由 LLM 自由生成,可能包含 Mermaid 图(架构图等)。调用后校验
384    /// 正文 Mermaid 块,坏块错误消息注入重试反馈;重试耗尽后降级(坏块转
385    /// text + 注释)而非失败——页面照常产出,坏图不出现在产物中。
386    /// 与 generate_wiki_page 的区别:这里只校验 Mermaid(无引用契约——
387    /// 全局文档不强制源码引用,与既有行为一致)。
388    /// 实现委托自由函数 `complete_with_mermaid_guard_free`(U03/D1:
389    /// Schema 文档复用同一校验-重试-降级路径),此处只负责调用计数。
390    async fn complete_with_mermaid_guard(
391        &self,
392        messages: Vec<Message>,
393        label: &str,
394    ) -> Result<String> {
395        complete_with_mermaid_guard_free(self.provider, messages, label, Some(&self.call_count))
396            .await
397    }
398
399    /// 生成架构概览页面
400    ///
401    /// 基于所有模块的生成输出和知识图谱,生成项目级的架构概览文档。
402    /// 生成前先为每个模块生成一行职责描述(describe_modules),
403    /// 填充到模块快照的 description 字段——架构 prompt 依此输出模块职责,
404    /// 取代原本恒为 None 的空描述(此前的 overview 只有模块名+节点数+边计数,
405    /// 无法表达"模块负责什么")。
406    pub async fn generate_architecture(
407        &self,
408        output: &GenerationOutput,
409        graph: &KnowledgeGraph,
410        config: &WikiConfig,
411        root: &crate::project::ProjectRoot,
412    ) -> Result<WikiDocument> {
413        let language = &config.wiki.language;
414        let modules = self.describe_modules(graph, language, config, root).await;
415        let messages =
416            prompt::architecture_overview_prompt(&modules, graph, language);
417        // LLM 调用计数在 complete_with_mermaid_guard 内部(含重试)
418        let content = self.complete_with_mermaid_guard(messages, "架构概览").await?;
419        let now = chrono::Utc::now().to_rfc3339();
420        // v32 10.2:架构页也带基线行(与模块页一致;OnceCell 复用同值)
421        let based_on_commit = self.head_short_for(root);
422
423        Ok(WikiDocument {
424            title: "架构概览".into(),
425            kind: DocumentKind::ArchitectureOverview,
426            content,
427            language: config.wiki.language.clone(),
428            module_path: vec![],
429            references: output
430                .cards
431                .iter()
432                .map(|c| Reference {
433                    target_title: c.module_name.clone(),
434                    target_path: format!(
435                        "wiki/{}/{}.md",
436                        config.wiki.language,
437                        // 模块页写盘文件名 = module_path.join("_")(见 output::wiki_file_name),
438                        // 链接必须与之一致,否则 TOC/概览出现断链
439                        c.module_name.replace("::", "_")
440                    ),
441                    relation: "module".into(),
442                })
443                .collect(),
444            last_updated: now,
445            based_on_commit,
446            fingerprint: None,
447        })
448    }
449
450    /// 为每个模块生成一行职责描述(LLM),返回带 description 的模块快照
451    ///
452    /// 逐模块一条 user 消息:输入 = 模块名 + 实体名列表 + 依赖模块列表,
453    /// 输出 = 一句话职责(≤30 字)。跳过 src 兜底模块(它吸收未聚类文件,
454    /// 无明确职责边界,描述会失真);LLM 失败时保留空描述(降级不影响主流程)。
455    /// 各模块描述**并行**生成(join_all 保留顺序)——串行在 10+ 模块仓库会
456    /// 拖长架构概览/项目概览的生成(真实 LLM 实测超时 10min 的根因之一)。
457    ///
458    /// v31 C-02 缓存:内存 memo(同一生成器实例内架构页+概览页两次调用
459    /// 只对同一模块 LLM 一次)+ 落盘缓存(.state/module_descriptions.json,
460    /// 键=模块名@语言,值=描述+模块文件内容指纹)——增量更新时未变模块
461    /// 直接复用上次描述,不再重复消耗 LLM token。
462    async fn describe_modules(
463        &self,
464        graph: &KnowledgeGraph,
465        language: &str,
466        config: &WikiConfig,
467        root: &crate::project::ProjectRoot,
468    ) -> Vec<crate::model::ModuleCluster> {
469        // 缓存文件路径与 generation_state.json 同目录(.state/),
470        // 随输出目录隔离(不同仓库/不同输出互不污染)
471        let cache_path = config.output_dir().join(".state").join("module_descriptions.json");
472        // 懒加载:首次调用读盘(损坏→空缓存,回退 LLM 重新生成不阻断)
473        {
474            let mut guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
475            if guard.is_none() {
476                *guard = Some(ModuleDescCache::load(&cache_path));
477            }
478        }
479        // 并行生成所有需描述的模块描述(保留输入顺序);Semaphore 限制
480        // 并发(演进计划 T5.1):0=不限时许可数巨大永不会阻塞。
481        let semaphore = self.semaphore.clone();
482        let futures: Vec<_> = graph
483            .modules
484            .iter()
485            .map(|module| {
486                let semaphore = semaphore.clone();
487                let cache_key = format!("{}@{}", module.name, language);
488                let fingerprint = module_files_fingerprint(module, graph, root);
489                async move {
490                    // 兜底模块(src)与空模块跳过:无职责边界可描述
491                    if module.name == "src" || module.node_ids.is_empty() {
492                        return module.clone();
493                    }
494                    // 缓存命中(指纹一致):直接复用,不触发 LLM 调用
495                    {
496                        let guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
497                        if let Some(cache) = guard.as_ref()
498                            && let Some(entry) = cache.entries.get(&cache_key)
499                            && entry.fingerprint == fingerprint
500                        {
501                            let mut enriched = module.clone();
502                            enriched.description = Some(entry.description.clone());
503                            return enriched;
504                        }
505                    }
506                    let _permit = match semaphore.acquire().await {
507                        Ok(p) => p,
508                        Err(_) => return module.clone(),
509                    };
510                    let mut enriched = module.clone();
511                    if let Ok(text) = self.describe_module(module, graph, language).await
512                        && !text.trim().is_empty()
513                    {
514                        let description = text.trim().to_string();
515                        // 写回缓存(锁内短操作,不跨 await)——失败不缓存,
516                        // 下次调用重新尝试 LLM
517                        {
518                            let mut guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
519                            if let Some(cache) = guard.as_mut() {
520                                cache.entries.insert(
521                                    cache_key,
522                                    CacheEntry {
523                                        fingerprint,
524                                        description: description.clone(),
525                                    },
526                                );
527                            }
528                        }
529                        enriched.description = Some(description);
530                    }
531                    enriched
532                }
533            })
534            .collect();
535        let modules = futures::future::join_all(futures).await;
536        // 整批完成后原子落盘(跨进程复用);写盘失败仅告警不阻断
537        {
538            let guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
539            if let Some(cache) = guard.as_ref() {
540                cache.save(&cache_path);
541            }
542        }
543        modules
544    }
545
546    /// 生成单个模块的一句话职责描述(LLM)
547    async fn describe_module(
548        &self,
549        module: &crate::model::ModuleCluster,
550        graph: &KnowledgeGraph,
551        language: &str,
552    ) -> Result<String> {
553        self.call_count.fetch_add(1, Ordering::Relaxed);
554
555        // 模块职责描述输入:实体名收集(t02 拍板:行为型优先排序 + 排除字段级)
556        let entity_names = collect_module_entity_names(module, graph);
557
558        let messages = prompt::module_description_prompt(
559            &module.name,
560            &entity_names,
561            language,
562        );
563        self.provider.complete(&messages).await
564    }
565
566    /// 生成项目概览页面
567    ///
568    /// 与 generate_architecture 同签名同风格:基于完整 KnowledgeGraph 的
569    /// 模块列表与模块间依赖摘要,生成全仓库概览(技术栈/目录结构/核心模块)。
570    pub async fn generate_overview(
571        &self,
572        output: &GenerationOutput,
573        graph: &KnowledgeGraph,
574        config: &WikiConfig,
575        root: &crate::project::ProjectRoot,
576    ) -> Result<WikiDocument> {
577        // 与 generate_architecture 一致:先补模块职责描述,概览内容才能
578        // 表达"模块负责什么";再叠加卡片摘要(自底向上合成:父概览基于
579        // 子模块的职责描述 + 卡片摘要生成,而非仅模块名/节点数/边计数)
580        // LLM 调用计数在 complete_with_mermaid_guard 内部(含重试)
581        let modules = self.describe_modules(graph, &config.wiki.language, config, root).await;
582        let messages = vec![Message::user(overview_prompt(&modules, &output.cards, graph, config))];
583        let content = self.complete_with_mermaid_guard(messages, "项目概览").await?;
584        let now = chrono::Utc::now().to_rfc3339();
585        // v32 10.2:概览页也带基线行(与模块页一致;OnceCell 复用同值)
586        let based_on_commit = self.head_short_for(root);
587
588        Ok(WikiDocument {
589            title: "项目概览".into(),
590            kind: DocumentKind::ProjectOverview,
591            content,
592            language: config.wiki.language.clone(),
593            module_path: vec![],
594            references: output
595                .cards
596                .iter()
597                .map(|c| Reference {
598                    target_title: c.module_name.clone(),
599                    target_path: format!(
600                        "wiki/{}/{}.md",
601                        config.wiki.language,
602                        // 模块页写盘文件名 = module_path.join("_")(见 output::wiki_file_name),
603                        // 链接必须与之一致,否则 TOC/概览出现断链
604                        c.module_name.replace("::", "_")
605                    ),
606                    relation: "module".into(),
607                })
608                .collect(),
609            last_updated: now,
610            based_on_commit,
611            fingerprint: None,
612        })
613    }
614}
615
616/// 模块职责描述输入:收集模块内实体名(t02 拍板:行为型优先排序 + 排除字段级)
617///
618/// 实体名额有限(DESCRIBE_ENTITY_CAP = 30),图节点顺序(解析顺序)不代表
619/// 职责信号强弱:函数/方法(Calls 边承载者)是模块职责的主要信号,结构体/
620/// 枚举/常量次之,字段(variable)对职责判断价值最低且数量大(t01 调研:
621/// 字段级实体占比约 1.29 倍)——不排序时字段会挤占函数名额,且字段是
622/// lint stale 假漂移的噪声来源(v20 审计:C# 私有字段曾被误报为页面声称
623/// 但源码不存在的实体)。排序规则:kind 优先级(函数族 > 数据族 > 常量)
624/// + 名称字典序(确定性输出,跨次生成一致),容器节点与 Variable 不进名额。
625pub(crate) const DESCRIBE_ENTITY_CAP: usize = 30;
626
627/// kind → 职责信号优先级(0 最高,仅排序用,不改变实体本身语义)
628fn entity_priority(kind: &crate::model::NodeKind) -> u8 {
629    match kind {
630        // 行为型:函数/方法/宏/接口/抽象——模块"做什么"的直接信号
631        crate::model::NodeKind::Function
632        | crate::model::NodeKind::Trait
633        | crate::model::NodeKind::Impl
634        | crate::model::NodeKind::Interface
635        | crate::model::NodeKind::Class
636        | crate::model::NodeKind::Macro => 0,
637        // 数据型:结构体/枚举/类型别名
638        crate::model::NodeKind::Struct
639        | crate::model::NodeKind::Enum
640        | crate::model::NodeKind::Type => 1,
641        // 常量
642        crate::model::NodeKind::Constant => 2,
643        // 容器与字段在上层过滤,不应到达此处
644        _ => 3,
645    }
646}
647
648/// 收集模块内实体名(排序 + 截断名额)
649pub(crate) fn collect_module_entity_names(
650    module: &crate::model::ModuleCluster,
651    graph: &KnowledgeGraph,
652) -> Vec<String> {
653    let mut names: Vec<(u8, String)> = module
654        .node_ids
655        .iter()
656        .filter_map(|nid| graph.graph.node_weight(*nid))
657        .filter(|n| {
658            !matches!(
659                n.kind,
660                crate::model::NodeKind::Project
661                    | crate::model::NodeKind::Module
662                    | crate::model::NodeKind::File
663                    | crate::model::NodeKind::Variable
664            )
665        })
666        .map(|n| (entity_priority(&n.kind), n.name.clone()))
667        .collect();
668    // 稳定排序:优先级升序 + 名称字典序(确定性输出)
669    names.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
670    names
671        .into_iter()
672        .map(|(_, name)| name)
673        .take(DESCRIBE_ENTITY_CAP)
674        .collect()
675}
676
677/// 带 Mermaid 校验的 LLM 调用(自由函数版,U03/D1 提取)
678///
679/// 与 WikiGenerator::complete_with_mermaid_guard 等价,但直接接受
680/// provider 与可选调用计数指针——Schema 文档生成(generate/schema.rs)
681/// 只有 Provider 没有 WikiGenerator,此前直接 complete 绕过校验(唯一
682/// 强制 erDiagram 输出的文档类型反而无校验-重试-降级,D1)。
683pub async fn complete_with_mermaid_guard_free<P: LlmProvider>(
684    provider: &P,
685    mut messages: Vec<Message>,
686    label: &str,
687    call_count: Option<&std::sync::atomic::AtomicUsize>,
688) -> Result<String> {
689    use std::sync::atomic::Ordering;
690    let mut content = String::new();
691    let mut last_mermaid = Vec::new();
692
693    for attempt in 0..=MERMAID_RETRY_MAX {
694        if let Some(c) = call_count {
695            c.fetch_add(1, Ordering::Relaxed);
696        }
697        content = provider.complete(&messages).await?;
698        last_mermaid = crate::output::mermaid_check::validate_mermaid_blocks(&content);
699        if last_mermaid.is_empty() {
700            return Ok(content);
701        }
702        tracing::warn!(
703            "{label} Mermaid 校验失败(第 {} 次,坏块 {} 个)",
704            attempt + 1,
705            last_mermaid.len()
706        );
707        if attempt == MERMAID_RETRY_MAX {
708            break;
709        }
710        messages.push(Message::user(
711            crate::output::mermaid_check::mermaid_retry_feedback(&last_mermaid),
712        ));
713    }
714
715    tracing::warn!("{label} Mermaid 重试耗尽({} 个坏块),降级为 text 块", last_mermaid.len());
716    Ok(crate::output::mermaid_check::degrade_mermaid_blocks(
717        &content,
718        &last_mermaid,
719    ))
720}
721
722/// 生成项目概览的 prompt(单条 user 消息,模板风格与 architecture_overview_prompt 一致)
723///
724/// 输入 = 模块列表(含职责描述)+ 卡片摘要(自底向上合成的一层:概览基于
725/// 子模块的卡片摘要生成)+ 模块间依赖摘要,输出 = 技术栈 / 目录结构 / 核心模块。
726fn overview_prompt(
727    modules: &[crate::model::ModuleCluster],
728    cards: &[crate::model::KnowledgeCard],
729    graph: &KnowledgeGraph,
730    config: &WikiConfig,
731) -> String {
732    let mut parts = Vec::new();
733
734    parts.push(format!(
735        "你是一个资深软件架构师,负责为整个项目生成人类可读的项目概览文档。\n\n\
736         请基于下面的模块聚类信息、各模块卡片摘要和模块间依赖摘要,输出以下结构:\n\n\
737         # 项目概览\n\n\
738         ## 技术栈\n根据模块名称与依赖关系推断项目使用的技术栈。\n\n\
739         ## 目录结构\n根据模块划分描述仓库的目录结构。\n\n\
740         ## 核心模块\n列出核心模块及其职责。\n\n\
741         请用 {} 语言输出。保留 Markdown 格式。",
742        config.wiki.language
743    ));
744
745    parts.push("## 模块列表".to_string());
746    for module in modules {
747        let desc = module.description.as_deref().unwrap_or("");
748        parts.push(format!(
749            "- {} (节点数: {}{})",
750            module.name,
751            module.node_ids.len(),
752            if desc.is_empty() {
753                String::new()
754            } else {
755                format!(", 职责: {}", desc)
756            }
757        ));
758    }
759
760    // 卡片摘要:模块级详情的浓缩(自底向上合成的输入)。
761    // 每卡片一行:模块名 + 摘要 + 关键实体(实体名列表,数量多于描述输入)
762    if !cards.is_empty() {
763        parts.push("## 模块卡片摘要".to_string());
764        for card in cards {
765            let entities: Vec<&str> = card
766                .key_entities
767                .iter()
768                .map(|e| e.name.as_str())
769                .take(8)
770                .collect();
771            parts.push(format!(
772                "- {}: {}(关键实体: {})",
773                card.module_name,
774                card.summary,
775                if entities.is_empty() {
776                    "无".to_string()
777                } else {
778                    entities.join(", ")
779                }
780            ));
781        }
782    }
783
784    // 模块间依赖摘要:建立 节点→模块 映射后,按模块对聚合非 Contains 边
785    let mut module_of: std::collections::HashMap<NodeId, &str> = Default::default();
786    for module in modules {
787        for nid in &module.node_ids {
788            module_of.insert(*nid, module.name.as_str());
789        }
790    }
791    let mut deps: std::collections::BTreeMap<(String, String), usize> = Default::default();
792    for edge in graph.graph.edge_weights() {
793        if edge.kind == EdgeKind::Contains {
794            continue;
795        }
796        let (Some(src), Some(dst)) = (module_of.get(&edge.source), module_of.get(&edge.target))
797        else {
798            continue;
799        };
800        *deps.entry((src.to_string(), dst.to_string())).or_default() += 1;
801    }
802    if deps.is_empty() {
803        parts.push("\n## 模块间依赖\n(图中未检测到模块间依赖边)".to_string());
804    } else {
805        parts.push("\n## 模块间依赖".to_string());
806        for ((src, dst), count) in deps {
807            parts.push(format!("- {} → {} ({} 条边)", src, dst, count));
808        }
809    }
810
811    parts.join("\n")
812}
813
814/// 从 Chunk 构建交叉引用
815///
816/// * `language` — 目标语言目录名,链接指向 `wiki/{language}/` 下的页面
817fn build_references(chunk: &Chunk, language: &str) -> Vec<Reference> {
818    chunk
819        .dependencies
820        .iter()
821        .map(|dep| Reference {
822            target_title: dep.clone(),
823            target_path: format!(
824                "wiki/{language}/{}.md",
825                // 依赖模块页文件名与输出层 wiki_file_name 一致("::" → "_")
826                dep.replace("::", "_")
827            ),
828            relation: "depends_on".into(),
829        })
830        .collect()
831}
832
833/// 确定性架构/概览骨架(U06/D12):provider 失败时降级而非丢页
834///
835/// 内容 = 模块列表(名 + 实体数 + 依赖清单),全部来自 knowledge graph
836/// (零 LLM 调用),与 index.md 的确定性骨架同一思路:页面存在性优先,
837/// LLM 摘要可在下次成功生成时补齐。kind/title 由调用方传入(架构概览与
838/// 项目概览共用,避免两份骨架代码漂移)。
839pub fn fallback_architecture_doc(
840    graph: &KnowledgeGraph,
841    config: &WikiConfig,
842    kind: DocumentKind,
843    title: &str,
844) -> WikiDocument {
845    use petgraph::visit::{EdgeRef, IntoEdgeReferences};
846    use std::collections::{BTreeMap, BTreeSet, HashMap};
847
848    // 实体节点 → 所属模块(先到先得,与 index.rs/export_modules 同规则)
849    let mut node_module: HashMap<NodeId, String> = HashMap::new();
850    for module in &graph.modules {
851        for nid in &module.node_ids {
852            node_module
853                .entry(*nid)
854                .or_insert_with(|| module.name.clone());
855        }
856    }
857    // 模块 → 依赖模块名集合(Calls + Imports 跨模块边,BTreeSet 字典序)
858    let mut deps: BTreeMap<String, BTreeSet<String>> = Default::default();
859    for edge in graph.graph.edge_references() {
860        if matches!(
861            graph.graph[edge.id()].kind,
862            EdgeKind::Calls | EdgeKind::Imports
863        ) {
864            let (Some(src), Some(tgt)) = (
865                node_module.get(&edge.source()),
866                node_module.get(&edge.target()),
867            ) else {
868                continue;
869            };
870            if src != tgt {
871                deps.entry(src.clone()).or_default().insert(tgt.clone());
872            }
873        }
874    }
875
876    let mut body = format!(
877        "# {title}\n\n> LLM 生成不可用,本页为确定性骨架:模块与依赖关系由知识图谱自动生成(无 LLM 摘要)。\n\n## 模块\n\n"
878    );
879    for module in &graph.modules {
880        body.push_str(&format!("- `{}`({} 个实体)", module.name, module.node_ids.len()));
881        if let Some(dl) = deps.get(&module.name)
882            && !dl.is_empty()
883        {
884            body.push_str(&format!(" — 依赖 {}", dl.iter().cloned().collect::<Vec<_>>().join(", ")));
885        }
886        body.push('\n');
887    }
888
889    let mut refs: Vec<Reference> = graph
890        .modules
891        .iter()
892        .map(|m| Reference {
893            target_title: m.name.clone(),
894            target_path: format!(
895                "wiki/{}/{}.md",
896                config.wiki.language,
897                m.name.replace("::", "_")
898            ),
899            relation: "module".into(),
900        })
901        .collect();
902    // 按目标标题字典序,保证输出确定性
903    refs.sort_by(|a, b| a.target_title.cmp(&b.target_title));
904
905    WikiDocument {
906        title: title.to_string(),
907        kind,
908        content: body,
909        language: config.wiki.language.clone(),
910        module_path: vec![],
911        references: refs,
912        last_updated: chrono::Utc::now().to_rfc3339(),
913        // 测试辅助构造:基于提交行由调用方显式指定(默认无)
914        based_on_commit: None,
915        fingerprint: None,
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use crate::generate::chunk::chunk_by_file;
923    use crate::generate::llm::MockProvider;
924    use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
925    
926    use std::path::PathBuf;
927
928    /// 可编程 mock:按调用次数依次返回预设响应(引用重试测试用)
929    struct ScriptedProvider {
930        responses: std::sync::Mutex<std::vec::IntoIter<String>>,
931        calls: std::sync::atomic::AtomicUsize,
932    }
933
934    impl ScriptedProvider {
935        fn new(responses: Vec<String>) -> Self {
936            Self {
937                responses: std::sync::Mutex::new(responses.into_iter()),
938                calls: std::sync::atomic::AtomicUsize::new(0),
939            }
940        }
941    }
942
943    impl LlmProvider for ScriptedProvider {
944        async fn complete(&self, _messages: &[Message]) -> Result<String> {
945            self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
946            self.responses
947                .lock()
948                .unwrap()
949                .next()
950                .ok_or_else(|| anyhow::anyhow!("预设响应耗尽"))
951        }
952    }
953
954    /// 前 n 次调用失败、之后成功的 provider(调用级重试测试用)
955    struct FlakyProvider {
956        fail_times: std::sync::atomic::AtomicUsize,
957        calls: std::sync::atomic::AtomicUsize,
958    }
959
960    impl FlakyProvider {
961        fn new(fail_times: usize) -> Self {
962            Self {
963                fail_times: std::sync::atomic::AtomicUsize::new(fail_times),
964                calls: std::sync::atomic::AtomicUsize::new(0),
965            }
966        }
967    }
968
969    impl LlmProvider for FlakyProvider {
970        async fn complete(&self, _messages: &[Message]) -> Result<String> {
971            self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
972            // 剩余失败次数 > 0 时失败,减到 0 后成功
973            let remaining = self.fail_times.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
974            if remaining > 0 {
975                Err(anyhow::anyhow!("模拟瞬时网络错误"))
976            } else {
977                Ok("重试成功".to_string())
978            }
979        }
980    }
981
982    #[tokio::test]
983    async fn test_complete_with_retry_recovers_after_transient_failure() {
984        // 首次失败、第二次成功:重试自愈,返回内容正确
985        let provider = FlakyProvider::new(1);
986        let content = complete_with_retry(&provider, &[], "src::test").await.unwrap();
987        assert_eq!(content, "重试成功");
988        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2);
989    }
990
991    #[tokio::test]
992    async fn test_complete_with_retry_gives_up_after_max_attempts() {
993        // 持续失败:重试耗尽后原样返回错误(不吞错、不掩盖)
994        let provider = FlakyProvider::new(10);
995        let err = complete_with_retry(&provider, &[], "src::test")
996            .await
997            .unwrap_err();
998        assert!(err.to_string().contains("模拟瞬时网络错误"));
999        assert_eq!(
1000            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1001            CALL_RETRY_MAX
1002        );
1003    }
1004
1005    fn make_test_chunk() -> Chunk {
1006        let entity = Entity {
1007            name: "Server".into(),
1008            kind: "struct".into(),
1009            line_start: 1,
1010            line_end: 50,
1011            doc_comment: Some("HTTP 服务".into()),
1012            signature: None, visibility: None,
1013        };
1014        let insight = FileInsight {
1015            path: PathBuf::from("src/server.rs"),
1016            language: "rust".into(),
1017            entities: vec![entity],
1018            imports: vec![ImportStmt {
1019                source: "tokio".into(),
1020                alias: None,
1021                line: 1,
1022            }],
1023            doc_comments: vec![],
1024            source: String::new(),
1025        };
1026        chunk_by_file(&insight)
1027    }
1028
1029    #[tokio::test]
1030    async fn test_skip_empty_chunk() {
1031        let provider = MockProvider::new();
1032        let generator = WikiGenerator::new(&provider, 0);
1033        let config = WikiConfig::default();
1034        let root = crate::project::ProjectRoot::new(std::env::temp_dir());
1035        let empty_chunk = Chunk {
1036            module_path: vec![],
1037            entities: vec![],
1038            imports: vec![],
1039            dependencies: vec![],
1040            file_paths: vec![],
1041            entity_sources: vec![],
1042        };
1043
1044        let result = generator.generate_wiki_page(&empty_chunk, "", &config, &root, None).await;
1045        assert!(result.is_err());
1046    }
1047
1048    /// 引用契约重试:无效引用 → 重试注入反馈 → 第二次输出有效引用则成功
1049    #[tokio::test]
1050    async fn test_wiki_page_retries_on_invalid_citation() {
1051        // 临时目录放一个真实文件 src/server.rs(3 行),使有效引用通过校验
1052        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_retry_{}", std::process::id()));
1053        let _ = std::fs::remove_dir_all(&dir);
1054        let src = dir.join("src");
1055        std::fs::create_dir_all(&src).unwrap();
1056        std::fs::write(src.join("server.rs"), "pub struct Server;\n// comment\n").unwrap();
1057        let root = crate::project::ProjectRoot::new(dir.clone());
1058
1059        // 第一次输出引用不存在的文件,第二次输出有效引用
1060        let provider = ScriptedProvider::new(vec![
1061            "模块职责是管理连接。核心实体 `Server` 定义见 nonexistent.rs:99。".to_string(),
1062            "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:1。".to_string(),
1063        ]);
1064        let generator = WikiGenerator::new(&provider, 0);
1065        let config = WikiConfig::default();
1066        let chunk = make_test_chunk();
1067
1068        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1069        assert!(doc.content.contains("src/server.rs:1"), "重试后应使用有效引用");
1070        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2, "应调用 2 次(1 次失败 + 1 次重试)");
1071
1072        let _ = std::fs::remove_dir_all(&dir);
1073    }
1074
1075    /// 引用契约重试耗尽:超过 CITATION_RETRY_MAX 仍无效 → 报错
1076    #[tokio::test]
1077    async fn test_wiki_page_bails_when_citations_never_valid() {
1078        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_fail_{}", std::process::id()));
1079        let _ = std::fs::remove_dir_all(&dir);
1080        let root = crate::project::ProjectRoot::new(dir.clone());
1081
1082        // 全部输出无效引用(文件不存在)
1083        let provider = ScriptedProvider::new(vec![
1084            "引用 nonexistent.rs:99".to_string(),
1085            "引用 nonexistent.rs:99".to_string(),
1086            "引用 nonexistent.rs:99".to_string(),
1087        ]);
1088        let generator = WikiGenerator::new(&provider, 0);
1089        let config = WikiConfig::default();
1090        let chunk = make_test_chunk();
1091
1092        let result = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await;
1093        assert!(result.is_err(), "重试耗尽后应报错");
1094        let err = result.unwrap_err().to_string();
1095        assert!(err.contains("引用校验失败"), "错误信息应说明引用校验失败: {err}");
1096        assert_eq!(
1097            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1098            CITATION_RETRY_MAX + 1,
1099            "应调用 CITATION_RETRY_MAX+1 次后放弃"
1100        );
1101
1102        let _ = std::fs::remove_dir_all(&dir);
1103    }
1104
1105    /// 引用契约放行:无引用的输出直接通过(契约只惩罚编造引用,不强制必须有)
1106    #[tokio::test]
1107    async fn test_wiki_page_without_citations_passes() {
1108        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_ok_{}", std::process::id()));
1109        let _ = std::fs::remove_dir_all(&dir);
1110        let root = crate::project::ProjectRoot::new(dir.clone());
1111
1112        let provider = ScriptedProvider::new(vec!["模块职责是管理连接。".to_string()]);
1113        let generator = WikiGenerator::new(&provider, 0);
1114        let config = WikiConfig::default();
1115        let chunk = make_test_chunk();
1116
1117        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1118        assert_eq!(doc.content, "模块职责是管理连接。");
1119        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 1, "无引用无需重试");
1120
1121        let _ = std::fs::remove_dir_all(&dir);
1122    }
1123
1124    /// G2 Mermaid 契约重试:坏图 → 重试注入错误反馈 → 第二次输出好图则成功
1125    #[tokio::test]
1126    async fn test_wiki_page_retries_on_bad_mermaid() {
1127        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_mermaid_retry_{}", std::process::id()));
1128        let _ = std::fs::remove_dir_all(&dir);
1129        let root = crate::project::ProjectRoot::new(dir.clone());
1130
1131        // 第一次输出坏图(标签未闭合),第二次输出好图
1132        let provider = ScriptedProvider::new(vec![
1133            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1134            "```mermaid\nflowchart LR\nA[Start] --> B[End]\n```\n".to_string(),
1135        ]);
1136        let generator = WikiGenerator::new(&provider, 0);
1137        let config = WikiConfig::default();
1138        let chunk = make_test_chunk();
1139
1140        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1141        assert!(doc.content.contains("A[Start] --> B[End]"), "重试后应保留好图");
1142        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2, "应调用 2 次(1 次坏图 + 1 次重试)");
1143
1144        let _ = std::fs::remove_dir_all(&dir);
1145    }
1146
1147    /// v14 B 组:区间重叠校验——文件存在且行号有效但引用区间不覆盖任何实体
1148    /// (行号对但内容错)→ 校验失败 → 重试反馈注入 → 修正后成功
1149    #[tokio::test]
1150    async fn test_wiki_page_retries_on_overlap_citation() {
1151        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_overlap_{}", std::process::id()));
1152        let _ = std::fs::remove_dir_all(&dir);
1153        let src = dir.join("src");
1154        std::fs::create_dir_all(&src).unwrap();
1155        // 10 行文件:实体区间 (2,4)(fn Server 定义)
1156        let content: String = (1..=10).map(|i| format!("line{i}\n")).collect();
1157        std::fs::write(src.join("server.rs"), content).unwrap();
1158        let root = crate::project::ProjectRoot::new(dir.clone());
1159
1160        let mut ranges: crate::output::citation::EntityRanges =
1161            crate::output::citation::EntityRanges::new();
1162        ranges.insert("src/server.rs".to_string(), vec![(2, 4)]);
1163
1164        // 第一次输出引用 8 行(文件内但不在实体区间),第二次输出 2 行(覆盖实体)
1165        let provider = ScriptedProvider::new(vec![
1166            "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:8。".to_string(),
1167            "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:2。".to_string(),
1168        ]);
1169        let generator = WikiGenerator::new(&provider, 0);
1170        let config = WikiConfig::default();
1171        let chunk = make_test_chunk();
1172
1173        let doc = generator
1174            .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
1175            .await
1176            .unwrap();
1177        assert!(doc.content.contains("src/server.rs:2"), "重试后应使用覆盖实体的引用");
1178        assert_eq!(
1179            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1180            2,
1181            "区间外引用应触发一次重试"
1182        );
1183
1184        let _ = std::fs::remove_dir_all(&dir);
1185    }
1186
1187    /// v14 B 组:区间重叠校验重试耗尽 → 页面失败(t03 拍板:维持 bail,
1188    /// 与文件级引用校验同一失败语义,Mermaid 才是唯一降级路径)
1189    #[tokio::test]
1190    async fn test_wiki_page_bails_when_overlap_never_valid() {
1191        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_overlap_fail_{}", std::process::id()));
1192        let _ = std::fs::remove_dir_all(&dir);
1193        let src = dir.join("src");
1194        std::fs::create_dir_all(&src).unwrap();
1195        let content: String = (1..=10).map(|i| format!("line{i}\n")).collect();
1196        std::fs::write(src.join("server.rs"), content).unwrap();
1197        let root = crate::project::ProjectRoot::new(dir.clone());
1198
1199        let mut ranges: crate::output::citation::EntityRanges =
1200            crate::output::citation::EntityRanges::new();
1201        ranges.insert("src/server.rs".to_string(), vec![(2, 4)]);
1202
1203        // 全部输出区间外引用(8 行)
1204        let provider = ScriptedProvider::new(vec![
1205            "核心实体 `Server` 见 src/server.rs:8。".to_string(),
1206            "核心实体 `Server` 见 src/server.rs:8。".to_string(),
1207            "核心实体 `Server` 见 src/server.rs:8。".to_string(),
1208        ]);
1209        let generator = WikiGenerator::new(&provider, 0);
1210        let config = WikiConfig::default();
1211        let chunk = make_test_chunk();
1212
1213        let result = generator
1214            .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
1215            .await;
1216        assert!(result.is_err(), "区间重叠校验重试耗尽应报错");
1217        let err = result.unwrap_err().to_string();
1218        assert!(err.contains("引用校验失败"), "错误信息应说明引用校验失败: {err}");
1219
1220        let _ = std::fs::remove_dir_all(&dir);
1221    }
1222
1223    /// v14 B 组:无实体文件(README.md 等非代码文件)的引用放行——区间
1224    /// 校验只对有实体的文件生效(引用配置/说明文件是合法行为)
1225    #[tokio::test]
1226    async fn test_wiki_page_passes_non_code_file_citation() {
1227        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_noncode_{}", std::process::id()));
1228        let _ = std::fs::remove_dir_all(&dir);
1229        std::fs::create_dir_all(&dir).unwrap();
1230        std::fs::write(dir.join("README.md"), "docs\n").unwrap();
1231        let root = crate::project::ProjectRoot::new(dir.clone());
1232
1233        // 实体表不含 README.md(无实体)
1234        let ranges: crate::output::citation::EntityRanges =
1235            crate::output::citation::EntityRanges::new();
1236        let provider = ScriptedProvider::new(vec![
1237            "模块说明见 README.md:1。".to_string(),
1238        ]);
1239        let generator = WikiGenerator::new(&provider, 0);
1240        let config = WikiConfig::default();
1241        let chunk = make_test_chunk();
1242
1243        let doc = generator
1244            .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
1245            .await
1246            .unwrap();
1247        assert!(doc.content.contains("README.md:1"), "无实体文件引用应放行");
1248        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 1, "无需重试");
1249
1250        let _ = std::fs::remove_dir_all(&dir);
1251    }
1252
1253    /// G2 Mermaid 契约重试耗尽:超过 MERMAID_RETRY_MAX 仍坏图 → 降级而非失败
1254    /// (坏块替换为 text fence + 标记注释,页面照常产出)
1255    #[tokio::test]
1256    async fn test_wiki_page_degrades_when_mermaid_never_valid() {
1257        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_mermaid_degrade_{}", std::process::id()));
1258        let _ = std::fs::remove_dir_all(&dir);
1259        let root = crate::project::ProjectRoot::new(dir.clone());
1260
1261        // 连续 3 次(MERMAID_RETRY_MAX + 1)都输出坏图
1262        let provider = ScriptedProvider::new(vec![
1263            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1264            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1265            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
1266        ]);
1267        let generator = WikiGenerator::new(&provider, 0);
1268        let config = WikiConfig::default();
1269        let chunk = make_test_chunk();
1270
1271        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
1272        assert!(!doc.content.contains("```mermaid"), "坏图不应再以 mermaid 块出现");
1273        assert!(doc.content.contains("```text"), "坏块应降级为 text fence");
1274        assert!(doc.content.contains("code-repo-wiki: mermaid parse failed"), "应含降级标记注释");
1275        assert_eq!(
1276            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
1277            MERMAID_RETRY_MAX + 1,
1278            "应调用 MERMAID_RETRY_MAX+1 次后降级"
1279        );
1280
1281        let _ = std::fs::remove_dir_all(&dir);
1282    }
1283
1284    /// G2 架构概览:坏图重试耗尽同样降级,页面正常产出
1285    #[tokio::test]
1286    async fn test_architecture_degrades_on_bad_mermaid() {
1287        let provider = ScriptedProvider::new(vec![
1288            "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
1289            "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
1290            "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
1291        ]);
1292        let generator = WikiGenerator::new(&provider, 0);
1293        let config = WikiConfig::default();
1294        let graph = crate::model::KnowledgeGraph::default();
1295        let output = crate::generate::GenerationOutput {
1296            cards: vec![],
1297            documents: vec![],
1298            generation_stats: crate::generate::GenerationStats::default(),
1299            timings: crate::GenerationTimings::default(),
1300        };
1301        // 临时根目录:避免默认输出目录污染工作区(缓存/产物落临时目录)
1302        let root = crate::project::ProjectRoot::new(
1303            std::env::temp_dir().join(format!("rw_arch_mermaid_{}", std::process::id())),
1304        );
1305
1306        let doc = generator
1307            .generate_architecture(&output, &graph, &config, &root)
1308            .await
1309            .unwrap();
1310        assert!(!doc.content.contains("```mermaid"), "坏图不应再以 mermaid 块出现");
1311        assert!(doc.content.contains("code-repo-wiki: mermaid parse failed"), "应含降级标记注释");
1312        let _ = std::fs::remove_dir_all(root.path());
1313    }
1314
1315    #[test]
1316    fn test_build_references() {
1317        let chunk = Chunk {
1318            module_path: vec!["crate".into(), "net".into()],
1319            entities: vec![],
1320            imports: vec![],
1321            dependencies: vec!["tokio".into(), "serde".into()],
1322            file_paths: vec![],
1323            entity_sources: vec![],
1324        };
1325
1326        let refs = build_references(&chunk, "zh");
1327        assert_eq!(refs.len(), 2);
1328        assert_eq!(refs[0].target_title, "tokio");
1329        assert_eq!(refs[0].target_path, "wiki/zh/tokio.md");
1330    }
1331
1332    /// 链接路径与写盘文件名规则必须一致("::" → "_",与 output::wiki_file_name 相同),
1333    /// 否则 TOC/概览/模块互链全部断链(历史 bug:曾用 "::" → "/" 生成 wiki/zh/src/analysis.md
1334    /// 而实际写盘 src_analysis.md)
1335    #[test]
1336    fn test_build_references_uses_underscore_like_write_path() {
1337        let chunk = Chunk {
1338            module_path: vec!["src".into(), "generate".into()],
1339            entities: vec![],
1340            imports: vec![],
1341            dependencies: vec!["src::analysis".into(), "src::output".into()],
1342            file_paths: vec![],
1343            entity_sources: vec![],
1344        };
1345
1346        let refs = build_references(&chunk, "zh");
1347        assert_eq!(refs[0].target_path, "wiki/zh/src_analysis.md");
1348        assert_eq!(refs[1].target_path, "wiki/zh/src_output.md");
1349    }
1350
1351    /// 模块职责描述 prompt:含模块名与实体列表,并约束一行输出
1352    #[test]
1353    fn test_module_description_prompt_shape() {
1354        let messages = prompt::module_description_prompt(
1355            "src::net",
1356            &["connect".into(), "listen".into()],
1357            "zh",
1358        );
1359        let user = &messages[1].content;
1360        assert!(user.contains("src::net"), "应含模块名");
1361        assert!(user.contains("connect"), "应含实体名");
1362        assert!(user.contains("listen"), "应含实体名");
1363        assert!(messages[0].content.contains("30"), "zh 应约束 30 字内");
1364    }
1365
1366    /// describe_modules:src 兜底模块跳过,带实体模块获得描述快照
1367    #[tokio::test]
1368    async fn test_describe_modules_enriches_description() {
1369        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1370        use petgraph::stable_graph::StableDiGraph;
1371
1372        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1373        let f = g.add_node(CodeNode {
1374            id: NodeId::new(0),
1375            kind: NodeKind::File,
1376            name: "net.rs".into(),
1377            file_path: Some("src/net.rs".into()),
1378            line_range: None,
1379            doc_comment: None,
1380            signature: None, visibility: None,
1381            module_path: vec!["src".into(), "net".into()],
1382        });
1383        let e = g.add_node(CodeNode {
1384            id: NodeId::new(1),
1385            kind: NodeKind::Function,
1386            name: "connect".into(),
1387            file_path: Some("src/net.rs".into()),
1388            line_range: None,
1389            doc_comment: None,
1390            signature: None, visibility: None,
1391            module_path: vec!["src".into(), "net".into()],
1392        });
1393        let kg = KnowledgeGraph {
1394            graph: g,
1395            modules: vec![
1396                ModuleCluster {
1397                    name: "src::net".into(),
1398                    node_ids: vec![f, e],
1399                    cohesion: 0.5,
1400                    coupling: 0.5,
1401                    description: None,
1402                },
1403                ModuleCluster {
1404                    name: "src".into(),
1405                    node_ids: vec![],
1406                    cohesion: 1.0,
1407                    coupling: 0.0,
1408                    description: None,
1409                },
1410            ],
1411            features: Vec::new(),
1412        };
1413        let provider = MockProvider::new();
1414        let generator = WikiGenerator::new(&provider, 0);
1415        // 输出目录指向临时目录,避免缓存文件污染工作区
1416        let config = crate::config::schema::WikiConfig {
1417            output_dir: Some(
1418                std::env::temp_dir()
1419                    .join(format!("rw_desc_cache_test_{}", std::process::id())),
1420            ),
1421            ..Default::default()
1422        };
1423        let root = crate::project::ProjectRoot::new(
1424            std::env::temp_dir().join(format!("rw_desc_root_test_{}", std::process::id())),
1425        );
1426        let enriched = generator.describe_modules(&kg, "zh", &config, &root).await;
1427        assert_eq!(enriched.len(), 2);
1428        assert!(enriched[0].description.is_some(), "带实体的模块应获得描述");
1429        assert_eq!(enriched[1].name, "src");
1430        assert!(enriched[1].description.is_none(), "src 兜底模块不描述");
1431    }
1432
1433    /// v31 C-02:同一生成器实例内第二次 describe_modules 命中缓存,
1434    /// 不再触发 LLM 调用(内存 memo)
1435    #[tokio::test]
1436    async fn test_describe_modules_cache_hit_skips_llm() {
1437        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1438        use petgraph::stable_graph::StableDiGraph;
1439
1440        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1441        g.add_node(CodeNode {
1442            id: NodeId::new(0),
1443            kind: NodeKind::File,
1444            name: "net.rs".into(),
1445            file_path: Some("src/net.rs".into()),
1446            line_range: None,
1447            doc_comment: None,
1448            signature: None,
1449            visibility: None,
1450            module_path: vec!["src".into(), "net".into()],
1451        });
1452        let kg = KnowledgeGraph {
1453            graph: g,
1454            modules: vec![
1455                ModuleCluster {
1456                    name: "src::net".into(),
1457                    node_ids: vec![NodeId::new(0)],
1458                    cohesion: 1.0,
1459                    coupling: 0.0,
1460                    description: None,
1461                },
1462            ],
1463            features: Vec::new(),
1464        };
1465        let provider = MockProvider::new();
1466        let generator = WikiGenerator::new(&provider, 0);
1467        // 测试泄漏根治(v33 审计发现):目录按 PID 命名且从不清理——Windows
1468        // PID 复用时上一进程的落盘缓存被本进程读到,缓存命中断言变成
1469        // 「残留命中」而偶发失败。开头清理保证从干净基线开始。
1470        let out_dir = std::env::temp_dir().join(format!("rw_desc_cache_hit_{}", std::process::id()));
1471        let _ = std::fs::remove_dir_all(&out_dir);
1472        let root_dir = std::env::temp_dir().join(format!("rw_desc_root_hit_{}", std::process::id()));
1473        let _ = std::fs::remove_dir_all(&root_dir);
1474        let config = crate::config::schema::WikiConfig {
1475            output_dir: Some(out_dir),
1476            ..Default::default()
1477        };
1478        let root = crate::project::ProjectRoot::new(root_dir);
1479        let first = generator.describe_modules(&kg, "zh", &config, &root).await;
1480        assert!(first[0].description.is_some(), "首次应走 LLM 获得描述");
1481        let calls_after_first = generator.llm_call_count();
1482        assert!(calls_after_first > 0, "首次必须真实调用 LLM");
1483        let second = generator.describe_modules(&kg, "zh", &config, &root).await;
1484        assert_eq!(second[0].description, first[0].description, "缓存应返回相同描述");
1485        assert_eq!(
1486            generator.llm_call_count(),
1487            calls_after_first,
1488            "第二次调用必须命中缓存、不触发 LLM"
1489        );
1490    }
1491
1492    /// v31 C-02:模块源文件内容变化后指纹失效,缓存不命中、重新调用 LLM
1493    #[tokio::test]
1494    async fn test_describe_modules_cache_invalidated_by_file_change() {
1495        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1496        use petgraph::stable_graph::StableDiGraph;
1497
1498        // 真实临时源文件(describe 指纹按文件内容计算)
1499        let dir = std::env::temp_dir().join(format!("rw_desc_cache_chg_{}", std::process::id()));
1500        let _ = std::fs::remove_dir_all(&dir);
1501        std::fs::create_dir_all(dir.join("src")).unwrap();
1502        std::fs::write(dir.join("src/net.rs"), "pub fn connect() {}\n").unwrap();
1503
1504        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1505        g.add_node(CodeNode {
1506            id: NodeId::new(0),
1507            kind: NodeKind::File,
1508            name: "net.rs".into(),
1509            file_path: Some("src/net.rs".into()),
1510            line_range: None,
1511            doc_comment: None,
1512            signature: None,
1513            visibility: None,
1514            module_path: vec!["src".into(), "net".into()],
1515        });
1516        let kg = KnowledgeGraph {
1517            graph: g,
1518            modules: vec![ModuleCluster {
1519                name: "src::net".into(),
1520                node_ids: vec![NodeId::new(0)],
1521                cohesion: 1.0,
1522                coupling: 0.0,
1523                description: None,
1524            }],
1525            features: Vec::new(),
1526        };
1527        let provider = MockProvider::new();
1528        let generator = WikiGenerator::new(&provider, 0);
1529        // 真实布局:root=dir(源文件在 root/src/ 下),产物在 root/.code-repo-wiki 下
1530        let config = crate::config::schema::WikiConfig {
1531            output_dir: Some(dir.join(".code-repo-wiki")),
1532            ..Default::default()
1533        };
1534        let root = crate::project::ProjectRoot::new(dir.clone());
1535        generator.describe_modules(&kg, "zh", &config, &root).await;
1536        let calls_after_first = generator.llm_call_count();
1537
1538        // 修改源文件内容 → 指纹变化 → 缓存失效
1539        std::fs::write(dir.join("src/net.rs"), "pub fn connect() {}\npub fn listen() {}\n").unwrap();
1540        generator.describe_modules(&kg, "zh", &config, &root).await;
1541        assert!(
1542            generator.llm_call_count() > calls_after_first,
1543            "文件内容变化后必须重新调用 LLM"
1544        );
1545        let _ = std::fs::remove_dir_all(&dir);
1546    }
1547
1548    /// v31 C-02:缓存文件损坏(非法 JSON)时回退空缓存,不 panic、
1549    /// 重新调用 LLM、正常产出(缓存故障绝不阻断主流程)
1550    #[tokio::test]
1551    async fn test_describe_modules_recovers_from_corrupt_cache() {
1552        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1553        use petgraph::stable_graph::StableDiGraph;
1554
1555        let dir = std::env::temp_dir().join(format!("rw_desc_cache_corrupt_{}", std::process::id()));
1556        let _ = std::fs::remove_dir_all(&dir);
1557        std::fs::create_dir_all(dir.join(".code-repo-wiki/.state")).unwrap();
1558        // 写入损坏的缓存文件(非法 JSON)
1559        std::fs::write(dir.join(".code-repo-wiki/.state/module_descriptions.json"), "{not-json").unwrap();
1560
1561        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1562        g.add_node(CodeNode {
1563            id: NodeId::new(0),
1564            kind: NodeKind::File,
1565            name: "net.rs".into(),
1566            file_path: Some("src/net.rs".into()),
1567            line_range: None,
1568            doc_comment: None,
1569            signature: None,
1570            visibility: None,
1571            module_path: vec!["src".into(), "net".into()],
1572        });
1573        let kg = KnowledgeGraph {
1574            graph: g,
1575            modules: vec![ModuleCluster {
1576                name: "src::net".into(),
1577                node_ids: vec![NodeId::new(0)],
1578                cohesion: 1.0,
1579                coupling: 0.0,
1580                description: None,
1581            }],
1582            features: Vec::new(),
1583        };
1584        let provider = MockProvider::new();
1585        let generator = WikiGenerator::new(&provider, 0);
1586        let config = crate::config::schema::WikiConfig {
1587            output_dir: Some(dir.join(".code-repo-wiki")),
1588            ..Default::default()
1589        };
1590        let root = crate::project::ProjectRoot::new(dir.clone());
1591        // 损坏缓存不应 panic,应走 LLM 重新生成并获得描述
1592        let modules = generator.describe_modules(&kg, "zh", &config, &root).await;
1593        assert!(modules[0].description.is_some(), "损坏缓存回退后应重新生成描述");
1594        assert!(generator.llm_call_count() > 0, "损坏缓存必须触发 LLM 调用");
1595        let _ = std::fs::remove_dir_all(&dir);
1596    }
1597
1598    /// 实体名额策略(t02 拍板):行为型优先排序 + 排除字段级(variable)
1599    #[test]
1600    fn test_collect_module_entity_names_prioritizes_behavior_over_fields() {
1601        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1602        use petgraph::stable_graph::StableDiGraph;
1603
1604        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1605        let mut ids = Vec::new();
1606        // 打乱插入顺序:变量与常量在前、函数在后——验证排序把行为型提到名额前排
1607        for i in 0..8 {
1608            ids.push(g.add_node(CodeNode {
1609                id: NodeId::new(i as usize),
1610                kind: NodeKind::Variable,
1611                name: format!("field_{i}"),
1612                file_path: None,
1613                line_range: None,
1614                doc_comment: None,
1615                signature: None,
1616                visibility: None,
1617                module_path: vec!["src".into(), "net".into()],
1618            }));
1619        }
1620        for i in 0..5 {
1621            ids.push(g.add_node(CodeNode {
1622                id: NodeId::new(100 + i as usize),
1623                kind: NodeKind::Constant,
1624                name: format!("const_{i}"),
1625                file_path: None,
1626                line_range: None,
1627                doc_comment: None,
1628                signature: None,
1629                visibility: None,
1630                module_path: vec!["src".into(), "net".into()],
1631            }));
1632        }
1633        // 10 个函数 + 5 个结构体 + 1 个文件容器
1634        for i in 0..10 {
1635            ids.push(g.add_node(CodeNode {
1636                id: NodeId::new(200 + i as usize),
1637                kind: NodeKind::Function,
1638                name: format!("fn_{i:02}"),
1639                file_path: None,
1640                line_range: None,
1641                doc_comment: None,
1642                signature: None,
1643                visibility: None,
1644                module_path: vec!["src".into(), "net".into()],
1645            }));
1646        }
1647        for i in 0..5 {
1648            ids.push(g.add_node(CodeNode {
1649                id: NodeId::new(300 + i as usize),
1650                kind: NodeKind::Struct,
1651                name: format!("struct_{i}"),
1652                file_path: None,
1653                line_range: None,
1654                doc_comment: None,
1655                signature: None,
1656                visibility: None,
1657                module_path: vec!["src".into(), "net".into()],
1658            }));
1659        }
1660        ids.push(g.add_node(CodeNode {
1661            id: NodeId::new(999),
1662            kind: NodeKind::File,
1663            name: "net.rs".into(),
1664            file_path: None,
1665            line_range: None,
1666            doc_comment: None,
1667            signature: None,
1668            visibility: None,
1669            module_path: vec!["src".into(), "net".into()],
1670        }));
1671        let module = ModuleCluster {
1672            name: "src::net".into(),
1673            node_ids: ids,
1674            cohesion: 0.5,
1675            coupling: 0.5,
1676            description: None,
1677        };
1678        let graph = KnowledgeGraph {
1679            graph: g,
1680            modules: vec![module.clone()],
1681            features: Vec::new(),
1682        };
1683
1684        let names = collect_module_entity_names(&module, &graph);
1685        // 总数 29(8 变量 + 5 常量 + 10 函数 + 5 结构体 + 1 容器,容器与字段排除)
1686        assert_eq!(names.len(), 20, "变量与容器不进入名额");
1687        assert!(
1688            !names.iter().any(|n| n.starts_with("field_")),
1689            "字段级实体(variable)应被排除"
1690        );
1691        assert!(
1692            !names.iter().any(|n| n == "net.rs"),
1693            "容器节点(File)应被排除"
1694        );
1695        // 行为型在前:函数全部先于结构体,结构体先于常量
1696        let fn_pos = names.iter().position(|n| n == "fn_00").unwrap();
1697        let struct_pos = names.iter().position(|n| n == "struct_0").unwrap();
1698        let const_pos = names.iter().position(|n| n == "const_0").unwrap();
1699        assert!(fn_pos < struct_pos && struct_pos < const_pos, "优先级序: 函数 < 结构体 < 常量");
1700        // 同级字典序(确定性):fn_00 在 fn_01 前
1701        assert!(names.iter().position(|n| n == "fn_00").unwrap() < names.iter().position(|n| n == "fn_01").unwrap());
1702    }
1703
1704    /// 名额截断:实体数超过 DESCRIBE_ENTITY_CAP 时只保留前 30 个(行为型优先)
1705    #[test]
1706    fn test_collect_module_entity_names_caps_at_30() {
1707        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
1708        use petgraph::stable_graph::StableDiGraph;
1709
1710        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
1711        let mut ids = Vec::new();
1712        for i in 0..40 {
1713            ids.push(g.add_node(CodeNode {
1714                id: NodeId::new(i as usize),
1715                kind: NodeKind::Function,
1716                name: format!("fn_{i:02}"),
1717                file_path: None,
1718                line_range: None,
1719                doc_comment: None,
1720                signature: None,
1721                visibility: None,
1722                module_path: vec!["src".into()],
1723            }));
1724        }
1725        let module = ModuleCluster {
1726            name: "src".into(),
1727            node_ids: ids,
1728            cohesion: 1.0,
1729            coupling: 0.0,
1730            description: None,
1731        };
1732        let graph = KnowledgeGraph {
1733            graph: g,
1734            modules: vec![module.clone()],
1735            features: Vec::new(),
1736        };
1737        let names = collect_module_entity_names(&module, &graph);
1738        assert_eq!(names.len(), DESCRIBE_ENTITY_CAP);
1739        assert_eq!(names[0], "fn_00", "字典序稳定");
1740        assert_eq!(names[29], "fn_29", "截断取前 30");
1741    }
1742
1743    /// 概览自底向上合成:overview_prompt 注入卡片摘要段(模块名+摘要+关键实体)
1744    #[test]
1745    fn test_overview_prompt_includes_card_summaries() {
1746        use crate::model::KnowledgeCard;
1747
1748        let graph = KnowledgeGraph::default();
1749        let config = WikiConfig::default();
1750        let card = KnowledgeCard {
1751            module_name: "src::net".into(),
1752            module_type: "module".into(),
1753            summary: "网络模块:连接管理与监听".into(),
1754            key_entities: vec![crate::model::EntitySummary {
1755                name: "connect".into(),
1756                kind: "function".into(),
1757                visibility: "public".into(),
1758                doc: None,
1759                source: None,
1760            }],
1761            dependencies: vec![],
1762            dependents: vec![],
1763            design_patterns: vec![],
1764            todo_notes: vec![],
1765            related_files: vec![],
1766            coding_spec: None,
1767            tech_stack: vec![],
1768            architecture: None,
1769            pending_manual_edits: vec![],
1770            features: Vec::new(),
1771        };
1772        let prompt = overview_prompt(&[], &[card], &graph, &config);
1773        assert!(prompt.contains("## 模块卡片摘要"), "应含卡片摘要节");
1774        assert!(prompt.contains("src::net"), "应含模块名");
1775        assert!(prompt.contains("网络模块"), "应含卡片摘要");
1776        assert!(prompt.contains("connect"), "应含关键实体");
1777    }
1778}