Skip to main content

code_repo_wiki/generate/
mod.rs

1pub mod card;
2pub mod chunk;
3pub mod embed;
4pub mod index;
5pub mod llm;
6pub mod prompt;
7pub mod schema;
8pub mod wiki;
9
10use std::collections::HashMap;
11use std::path::Path;
12use std::time::Instant;
13
14use anyhow::Result;
15
16use crate::config::schema::WikiConfig;
17use crate::ingest::parser::FileInsight;
18use crate::model::{KnowledgeCard, KnowledgeGraph, WikiDocument};
19
20use self::card::CardGenerator;
21use self::chunk::Chunk;
22use self::llm::{AnthropicProvider, LlmProvider, OpenAiProvider, Provider};
23use self::wiki::WikiGenerator;
24
25/// 生成流水线的输出
26pub struct GenerationOutput {
27    pub cards: Vec<KnowledgeCard>,
28    pub documents: Vec<WikiDocument>,
29    pub generation_stats: GenerationStats,
30    /// v32 8.1:分块/卡片/Wiki 页三段的内部计时(毫秒)——上层
31    /// run_pipeline_with_progress 收集后落盘供 bench 回放剖析
32    pub timings: crate::GenerationTimings,
33}
34
35/// 生成统计信息
36#[derive(Debug, Clone, Default)]
37pub struct GenerationStats {
38    pub total_tokens_used: usize,
39    pub llm_calls: usize,
40    pub generation_time_ms: u64,
41    /// 生成失败的模块名列表(演进计划 T3.2 失败隔离的可见性出口)
42    pub failed_modules: Vec<String>,
43}
44
45/// 根据配置创建 LLM Provider(v17 t02:协议按 provider 类型显式绑定)
46pub fn create_provider(config: &WikiConfig) -> Result<Provider> {
47    match config.llm.provider {
48        // openai = OpenAI Responses API 协议(base_url 可配,DeepSeek 归此)
49        crate::config::schema::LlmProviderType::OpenAI => {
50            Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Responses)?))
51        }
52        crate::config::schema::LlmProviderType::Anthropic => {
53            Ok(Provider::Anthropic(AnthropicProvider::new(&config.llm)?))
54        }
55        // openai-compatible = chat/completions 协议(custom 并入,v17 t02)
56        crate::config::schema::LlmProviderType::OpenAiCompatible => {
57            Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Chat)?))
58        }
59        crate::config::schema::LlmProviderType::Mock => {
60            // 本地模拟:测试/CI/无 API Key 场景,返回固定文本
61            Ok(Provider::Mock(crate::generate::llm::MockProvider::new()))
62        }
63    }
64}
65
66/// 运行完整的生成流水线
67///
68/// 1. AST 感知分块(按模块分组)
69/// 2. 并行生成 Knowledge Card
70/// 3. 串行生成 Wiki 页面(依赖前序卡片摘要)
71/// 4. 生成架构概览页面
72///
73/// extra_edits:本次运行新检测到的人工修改记录(模块名 → 记录文本),
74/// 生成卡片前注入 LLM 输入(见 CardGenerator::generate_all_cards);
75/// 由上层(lib.rs)从状态指纹比对结果组装,无人工修改时传空表。
76/// v32 9.2:按 [wiki.guide] 过滤与排序 chunk 列表(生成引导)。
77///
78/// - `pages` 非空时仅保留模块路径前缀匹配任一条目的 chunk(未匹配模块
79///   不生成独立页,但 overview/架构等全局文档仍全量汇总不受影响)。
80///   条目分隔符兼容 `/`、`::`、`\`(如 `src/net` 与 `src::net` 等价),
81///   前缀比较按模块名分段(`src/net` 匹配模块 `src::net::tcp`)。
82/// - `strict_empty=true`(全量路径):过滤后为空会显式报错——避免用户
83///   pages 配置笔误导致「以为生成了实际没有」的静默失败。增量路径
84///   (`strict_empty=false`)中受影响模块都不在白名单属正常空集(无
85///   页面需更新),记录日志后返回空,不报错。
86/// - `priority` 按条目顺序稳定排序(前缀匹配的模块前置),未匹配模块
87///   保持原顺序;排序只影响生成顺序,不改变产物内容。
88fn filter_chunks_by_guide(
89    chunks: Vec<Chunk>,
90    guide: &crate::config::schema::WikiGuideSection,
91    strict_empty: bool,
92) -> Result<Vec<Chunk>> {
93    if guide.pages.is_empty() {
94        return Ok(chunks);
95    }
96    let original_len = chunks.len();
97    let mut filtered: Vec<Chunk> = chunks
98        .into_iter()
99        .filter(|c| guide.pages.iter().any(|p| guide_prefix_match(&c.module_path, p)))
100        .collect();
101    if filtered.is_empty() && original_len > 0 {
102        if strict_empty {
103            anyhow::bail!(
104                "[wiki.guide].pages 未匹配任何模块(共 {} 个模块),请检查 pages 配置",
105                original_len
106            );
107        }
108        tracing::info!("增量生成: 受影响模块均不在 [wiki.guide].pages 白名单,跳过生成");
109    }
110    if !guide.priority.is_empty() {
111        filtered.sort_by_key(|c| {
112            guide
113                .priority
114                .iter()
115                .position(|p| guide_prefix_match(&c.module_path, p))
116                .unwrap_or(usize::MAX)
117        });
118    }
119    Ok(filtered)
120}
121
122/// [wiki.guide] 前缀匹配:把 pattern 按 `/`/`::`/`\` 拆成段,与模块路径
123/// 段(Vec<String>,来自模块名 split("::"))做前缀比较。
124fn guide_prefix_match(module_path: &[String], pattern: &str) -> bool {
125    let pat: Vec<&str> = pattern
126        .split(['/', ':', '\\'])
127        .filter(|s| !s.is_empty())
128        .collect();
129    if pat.is_empty() {
130        return false;
131    }
132    module_path
133        .iter()
134        .take(pat.len())
135        .map(|s| s.as_str())
136        .eq(pat.iter().copied())
137}
138
139pub async fn run_generation(
140    graph: &KnowledgeGraph,
141    insights: &[FileInsight],
142    config: &WikiConfig,
143    root: &crate::project::ProjectRoot,
144    extra_edits: &HashMap<String, Vec<String>>,
145) -> Result<GenerationOutput> {
146    let start = Instant::now();
147    // v32 8.1:三段内部计时(chunk/card/wiki)
148    let chunk_start = Instant::now();
149
150    // 1. AST 感知分块
151    let chunks = if graph.modules.is_empty() {
152        tracing::warn!("未检测到模块聚类,回退到文件级分块");
153        insights
154            .iter()
155            .map(chunk::chunk_by_file)
156            .collect::<Vec<_>>()
157    } else {
158        chunk::chunk_by_module(insights, &graph.modules, graph)
159    };
160    // v31 修复(C-03):分块后统一剔除空 chunk——chunk_by_module 对全部模块
161    // 产 chunk,增量只喂变更文件时未变更模块 chunk 为空;空 chunk 是确定性
162    // 「无内容」而非生成失败,若放行会在生成循环里被空块 bail 记入
163    // failed_modules(毒化 should_skip_noop 并引发无关模块补偿重试),且
164    // 过滤必须发生在管线入口,保证 chunks/cards/wiki/backfill 全链路 1:1 对齐。
165    let chunks: Vec<_> = chunks
166        .into_iter()
167        .filter(|c| !c.is_empty())
168        .collect();
169    // v32 9.2:生成引导过滤(全量路径——空匹配显式报错,见 filter 注释)
170    let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, true)?;
171    tracing::info!("生成进度: 30% - 分块完成,共 {} 个块", chunks.len());
172    let chunk_ms = chunk_start.elapsed().as_millis() as u64;
173
174    // 2. 创建 LLM Provider
175    let provider = create_provider(config)?;
176
177    // 3. 并行生成 Knowledge Card
178    let card_start = Instant::now();
179    let card_gen = CardGenerator::new(
180        &provider,
181        config.clone(),
182        crate::config::schema::LLM_MAX_CONCURRENT,
183        config.wiki.language.clone(),
184    );
185    let mut cards = card_gen
186        .generate_all_cards(&chunks, extra_edits)
187        .await?;
188    // 特征追溯回填(演进计划 T3.3):模块实体与特征实体的交集 → 特征名
189    backfill_features(&mut cards, &chunks, graph);
190    tracing::info!("生成进度: 60% - 知识卡片生成完成,共 {} 个卡片", cards.len());
191    let card_ms = card_start.elapsed().as_millis() as u64;
192
193    // 4. 按语言独立生成 Wiki 页面(并行,演进计划 T3.1;卡片仅主语言生成一次,
194    // 各语言页面复用主语言卡片摘要;语言列表在 generate_wiki_pages 内部计算)
195    let wiki_start = Instant::now();
196    let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
197    let mut documents =
198        generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
199    tracing::info!("生成进度: 90% - Wiki 页面生成完成,共 {} 个页面", documents.len());
200    let wiki_ms = wiki_start.elapsed().as_millis() as u64;
201
202    // 5. 生成全局文档(架构概览 + 数据库 Schema,全量/增量共用同一辅助函数)
203    generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &GlobalDocAffected::all(), false).await?;
204
205    let elapsed = start.elapsed();
206    let stats = GenerationStats {
207        llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
208        generation_time_ms: elapsed.as_millis() as u64,
209        // 失败隔离统计(T3.2):卡片与页面两路失败模块名合并
210        failed_modules: {
211            let mut f = card_gen.failed_modules();
212            f.extend(wiki_gen.failed_modules());
213            f
214        },
215        ..Default::default()
216    };
217
218    Ok(GenerationOutput {
219        cards,
220        documents,
221        generation_stats: stats,
222        timings: crate::GenerationTimings {
223            chunk_ms,
224            card_ms,
225            wiki_ms,
226            ..Default::default()
227        },
228    })
229}
230
231/// 增量更新的过滤生成流水线
232///
233/// 与 `run_generation` 类似,但仅处理 `inc`(增量分析结果)中列出的变更
234/// 文件 + 语义传播判定的受影响模块,用于增量更新场景。未变更的文件
235/// 使用已有缓存,不触发新的 LLM 调用。
236/// extra_edits 语义同 run_generation(本次新检测的人工修改记录)。
237pub async fn run_generation_filtered(
238    graph: &KnowledgeGraph,
239    insights: &[FileInsight],
240    config: &WikiConfig,
241    root: &crate::project::ProjectRoot,
242    inc: &crate::incremental::IncrementalResult,
243    extra_edits: &HashMap<String, Vec<String>>,
244) -> Result<GenerationOutput> {
245    let start = Instant::now();
246    let changed_files = &inc.changed_files;
247    let entity_changes = &inc.entity_changes;
248    let affected_modules = &inc.affected_modules;
249    // v32 8.1:三段内部计时
250    let chunk_start = Instant::now();
251
252    // 过滤出变更文件的 Insight(克隆为拥有数据)。
253    // T2 传播闭环接线:除变更文件外,语义传播判定的受影响模块文件也
254    // 并入生成范围——签名/删除等接口级变化会重生成依赖方模块的文档,
255    // 实现级变化(body-only)传播结果只含本模块,行为不变。
256    let affected_files = crate::incremental::impact::module_files(affected_modules, graph);
257    // v23 A1 实体级分类:从生成范围排除「无实体变更」文件——git diff 报告了
258    // 变化(文件在 changed_files),但实体级分类(change.rs 三元组全等判定)
259    // 未产出任何该文件的变更记录(纯注释/空白/换行符变化)。
260    // added/deleted 文件必有 Added/Removed 记录、接口级变化必有记录,故
261    // 无记录 = 仅非实体文本变化,模块页无需重生成(旧产物内容与行号引用
262    // 均仍准确)。排除后落入下方空集分支走快照回填(零 LLM 保留旧产物)。
263    // 与 incremental/mod.rs 的传播起点剔除共用同一函数,保证两处同口径。
264    let no_entity_change_files = crate::incremental::change::no_entity_change_files(
265        changed_files,
266        entity_changes,
267        root,
268    );
269    let mut changed_insights: Vec<FileInsight> = insights
270        .iter()
271        .filter(|f| {
272            (changed_files.contains(&f.path) || affected_files.contains(&f.path))
273                && !no_entity_change_files.contains(&f.path)
274        })
275        .cloned()
276        .collect();
277
278    // 纯删除场景的模块级补偿(v21 验证轮起,此处补全 mixed 场景):
279    // 被删文件所属模块(快照卡片 related_files 含被删文件且仍有存活
280    // 文件的卡片 = 部分删除模块)的存活文件并入变更集,走正常重生成
281    // 清除被删实体的页面残留。
282    //
283    // 补偿必须独立于下方空集回填分支执行:删除与修改并存(mixed)时
284    // changed_insights 非空,回填分支不进入——而语义传播的起点(被删
285    // 文件)在当前图中无节点(impact.rs find_start_nodes 找不到即跳过),
286    // 其模块永远进不了 affected_modules,不显式并入则模块页残留旧内容。
287    // 纯删除场景由本逻辑并入后同样落入正常生成路径;快照缺失/损坏时
288    // 跳过补偿(下方回填分支对快照失败有全量回退兜底,不丢数据)。
289    // 模块归属沿用快照 cards.related_files(与 v22 失败补偿同源机制)。
290    let deleted_files: std::collections::HashSet<std::path::PathBuf> = changed_files
291        .iter()
292        .filter(|f| !root.path().join(f).exists())
293        .cloned()
294        .collect();
295    let surviving_files: std::collections::HashSet<std::path::PathBuf> = if deleted_files.is_empty() {
296        std::collections::HashSet::new()
297    } else if let Ok(content) =
298        std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
299        && let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
300    {
301        snapshot
302            .cards
303            .iter()
304            .filter(|c| {
305                !c.related_files.is_empty()
306                    && c.related_files.iter().any(|f| deleted_files.contains(Path::new(f)))
307                    && c.related_files.iter().any(|f| root.path().join(f).exists())
308            })
309            .flat_map(|c| c.related_files.iter().map(std::path::PathBuf::from))
310            .collect()
311    } else {
312        std::collections::HashSet::new()
313    };
314    if !surviving_files.is_empty() {
315        let mut present: std::collections::HashSet<std::path::PathBuf> =
316            changed_insights.iter().map(|i| i.path.clone()).collect();
317        let mut merged = 0usize;
318        for insight in insights {
319            if surviving_files.contains(&insight.path) && present.insert(insight.path.clone()) {
320                changed_insights.push(insight.clone());
321                merged += 1;
322            }
323        }
324        if merged > 0 {
325            tracing::info!(
326                "增量生成: 删除文件所属模块的 {} 个存活文件并入变更集,重生成清除被删实体残留",
327                merged
328            );
329        }
330    }
331
332    if changed_insights.is_empty() {
333        // 空集场景(v23 A1 起含「无实体变更」文件:纯空白/注释/换行符变化
334        // 被实体级分类排除;v21 验证轮起含「整模块全删」:删除补偿未命中
335        // 任何部分删除模块):changed_files 非空但无文件命中影响集时,旧实现
336        // 直接返回空输出 → render_all 不写任何产物 → cleanup_stale_outputs
337        // 差集语义把**全部**旧产物清空(无关模块页也被删)。
338        // 修复:从导出快照回填未删除模块的旧产物(零 LLM 成本);
339        // 快照缺失(异常)时回退全量生成,宁可多生成也不丢数据。
340        if let Ok(content) = std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
341            && let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
342        {
343            // 快照回填:仅剔除整模块全删(related_files 全部不存在)的卡片与
344            // 文档——部分删除模块的存活文件已在上方并入变更集走重生成,此处
345            // 到达的只有「真无变更可生成」的文件,原样回填旧产物。
346            let deleted_modules: std::collections::HashSet<String> = snapshot
347                .cards
348                .iter()
349                .filter(|c| {
350                    !c.related_files.is_empty()
351                        && c.related_files.iter().all(|f| !root.path().join(f).exists())
352                })
353                .map(|c| c.module_name.clone())
354                .collect();
355            let cards: Vec<KnowledgeCard> = snapshot
356                .cards
357                .into_iter()
358                .filter(|c| !deleted_modules.contains(&c.module_name))
359                .collect();
360            let documents: Vec<WikiDocument> = snapshot
361                .documents
362                .into_iter()
363                .filter(|d| !deleted_modules.contains(&d.title))
364                .collect();
365            tracing::info!(
366                "增量生成: 空集场景({} 个变更文件),从快照回填 {} 文档 {} 卡片(跳过已删模块 {} 个)",
367                changed_files.len(),
368                documents.len(),
369                cards.len(),
370                deleted_modules.len()
371            );
372            return Ok(GenerationOutput {
373                cards,
374                documents,
375                generation_stats: GenerationStats::default(),
376                timings: crate::GenerationTimings::default(),
377            });
378        } else {
379            tracing::warn!("增量生成: 纯删除场景但导出快照缺失,回退全量生成防止产物误清");
380            // 回退全量:所有现存文件视为变更,走下方正常生成路径
381            changed_insights = insights.to_vec();
382        }
383    } else {
384        tracing::info!("增量生成: {} 个文件变更", changed_insights.len());
385    }
386
387    // 1. AST 感知分块(仅变更文件)
388    let chunks: Vec<_> = if graph.modules.is_empty() {
389        changed_insights
390            .iter()
391            .map(chunk::chunk_by_file)
392            .collect()
393    } else {
394        // 按模块重新组织变更文件,保持模块上下文
395        chunk::chunk_by_module(&changed_insights, &graph.modules, graph)
396    };
397    // v31 修复(C-03):同全量路径——管线入口剔除空 chunk(增量模式未变更
398    // 模块),保证 chunks/cards/wiki/backfill 全链路 1:1 对齐,且空 chunk
399    // 不会经空块 bail 污染 failed_modules。
400    let chunks: Vec<_> = chunks
401        .into_iter()
402        .filter(|c| !c.is_empty())
403        .collect();
404    // v32 9.2:生成引导过滤(增量路径——受影响模块不在白名单=正常空集,
405    // 不报错;白名单只约束「是否生成」,不改变增量影响传播判定本身)
406    let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, false)?;
407    tracing::info!("增量分块完成: {} 个块", chunks.len());
408    let chunk_ms = chunk_start.elapsed().as_millis() as u64;
409
410    // 2. 创建 LLM Provider
411    let provider = create_provider(config)?;
412
413    // 3. 并行生成 Knowledge Card(仅变更块)
414    let card_start = Instant::now();
415    let card_gen = CardGenerator::new(
416        &provider,
417        config.clone(),
418        crate::config::schema::LLM_MAX_CONCURRENT,
419        config.wiki.language.clone(),
420    );
421    let mut cards = card_gen
422        .generate_all_cards(&chunks, extra_edits)
423        .await?;
424    // 特征追溯回填(演进计划 T3.3):模块实体与特征实体的交集 → 特征名
425    backfill_features(&mut cards, &chunks, graph);
426    let card_ms = card_start.elapsed().as_millis() as u64;
427
428    // 4. 按语言独立生成 Wiki 页面(并行,演进计划 T3.1;仅变更块;卡片仅主语言生成一次,
429    // 各语言页面复用主语言卡片摘要)
430    let wiki_start = Instant::now();
431    let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
432    let mut documents =
433        generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
434    let wiki_ms = wiki_start.elapsed().as_millis() as u64;
435
436    // 5. 生成全局文档(架构概览 + 数据库 Schema)
437    // P1-2 全局文档增量(受影响判断):架构/概览只在接口级实体变化
438    // (新增/删除/签名变更)时重生成——纯实现级(body-only)变化不改变
439    // 模块间依赖视图;Schema 只在本次变更含 .sql 文件时重生成。未受影响的
440    // 全局文档从导出快照回填旧版(零 LLM 成本,渲染幂等不误判人工修改),
441    // 快照不可用时回退生成保证页面存在性。全量路径恒全受影响(all())。
442    let global_affected = GlobalDocAffected {
443        architecture: entity_changes.has_interface_change(),
444        schema: changed_files
445            .iter()
446            .any(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sql"))),
447    };
448    generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &global_affected, inc.has_deleted_files).await?;
449
450    let elapsed = start.elapsed();
451    let stats = GenerationStats {
452        llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
453        generation_time_ms: elapsed.as_millis() as u64,
454        // 失败隔离统计(T3.2):卡片与页面两路失败模块名合并
455        failed_modules: {
456            let mut f = card_gen.failed_modules();
457            f.extend(wiki_gen.failed_modules());
458            f
459        },
460        ..Default::default()
461    };
462
463    Ok(GenerationOutput {
464        cards,
465        documents,
466        generation_stats: stats,
467        timings: crate::GenerationTimings {
468            chunk_ms,
469            card_ms,
470            wiki_ms,
471            ..Default::default()
472        },
473    })
474}
475
476/// 从全仓库解析结果构建"相对路径 → 实体行区间列表"表(v14 B 组)
477///
478/// 供引用区间重叠校验使用(validate_citations_against_entities):
479/// 键用 norm_sep 归一化的相对路径(与引用提取的正斜杠形态统一,Windows
480/// 下不归一化会恒不命中),值 = 该文件全部实体的 (line_start, line_end)。
481///
482/// 必须用**全仓库** insights 而非变更文件子集——wiki 页面可能引用模块外
483/// 文件(跨模块引用是正常行为),只传变更集会导致模块外引用全部误判
484/// 为"无实体文件"而放行(区间校验失效)。
485fn build_entity_ranges(insights: &[FileInsight]) -> crate::output::citation::EntityRanges {
486    insights
487        .iter()
488        .map(|insight| {
489            let key = crate::incremental::norm_sep(&insight.path.to_string_lossy());
490            let ranges: Vec<(usize, usize)> = insight
491                .entities
492                .iter()
493                .map(|e| (e.line_start, e.line_end))
494                .collect();
495            (key, ranges)
496        })
497        .collect()
498}
499
500/// 特征追溯回填(演进计划 T3.3)
501///
502/// 模块涉及的实体级特征 = 模块 chunk 实体名与特征实体名集合的交集。
503/// 特征名列表写入卡片(render_knowledge_card 渲染"特征追溯"节),
504/// 提供"功能 → 实现它的模块"的可追溯视图(RepoSummary 的 traceability)。
505/// 特征实体名经 graph 反查 NodeId 得到;不经过 LLM,杜绝幻觉。
506fn backfill_features(cards: &mut [KnowledgeCard], chunks: &[Chunk], graph: &KnowledgeGraph) {
507    if graph.features.is_empty() || cards.is_empty() {
508        return;
509    }
510    // 预构建 特征名 → 实体名集合(避免每张卡片重复遍历图)
511    let feature_entities: Vec<(String, std::collections::HashSet<String>)> = graph
512        .features
513        .iter()
514        .map(|f| {
515            let names: std::collections::HashSet<String> = f
516                .node_ids
517                .iter()
518                .filter_map(|nid| graph.graph.node_weight(*nid).map(|n| n.name.clone()))
519                .collect();
520            (f.name.clone(), names)
521        })
522        .collect();
523    for (card, chunk) in cards.iter_mut().zip(chunks) {
524        let entity_names: std::collections::HashSet<&str> =
525            chunk.entities.iter().map(|e| e.name.as_str()).collect();
526        let mut matched: Vec<String> = feature_entities
527            .iter()
528            .filter(|(_, names)| names.iter().any(|n| entity_names.contains(n.as_str())))
529            .map(|(name, _)| name.clone())
530            .collect();
531        matched.sort();
532        card.features = matched;
533    }
534}
535
536// 实体摘要生成已删除(v31):原 generate_entity_summaries 对每实体一次
537// LLM 调用(全量 1500 实体=1500 次调用),但 Entity.summary 字段零消费者
538// (全仓库仅自身写入/过滤读取)——纯 token 浪费。未来如需实体级语义索引,
539// 应在生成时预索引重建,而非逐个惰性调用。
540
541/// 按语言并行生成 Wiki 页面(演进计划 T3.1 并行化)
542///
543/// 卡片摘要按 chunk 索引一一对应;并发受 max_concurrent 信号量控制,
544/// join_all 保序收集——与串行版的产出顺序一致,页面集合不变。
545/// 失败页面跳过并告警(不中断整体生成)。
546async fn generate_wiki_pages<P: LlmProvider>(
547    wiki_gen: &WikiGenerator<'_, P>,
548    chunks: &[Chunk],
549    cards: &[KnowledgeCard],
550    config: &WikiConfig,
551    max_concurrent: usize,
552    root: &crate::project::ProjectRoot,
553    entity_ranges: &crate::output::citation::EntityRanges,
554) -> Vec<WikiDocument> {
555    let languages = crate::output::wiki_languages(config);
556    let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1)));
557    let mut handles = Vec::with_capacity(chunks.len() * languages.len());
558    // 记录每个任务的模块名(失败时写入 wiki_gen 的失败列表,T3.2)
559    let mut task_modules = Vec::with_capacity(chunks.len() * languages.len());
560    for lang in &languages {
561        let mut lang_cfg = config.clone();
562        lang_cfg.wiki.language = lang.clone();
563        for (i, chunk) in chunks.iter().enumerate() {
564            let card_summary = cards.get(i).map(|c| c.summary.clone()).unwrap_or_default();
565            let semaphore = semaphore.clone();
566            let lang_cfg = lang_cfg.clone();
567            task_modules.push(chunk.module_path.join("::"));
568            handles.push(async move {
569                let _permit = semaphore
570                    .acquire()
571                    .await
572                    .map_err(|_| anyhow::anyhow!("信号量已关闭"))?;
573                wiki_gen
574                    .generate_wiki_page(chunk, &card_summary, &lang_cfg, root, Some(entity_ranges))
575                    .await
576            });
577        }
578    }
579
580    let results = futures::future::join_all(handles).await;
581    task_modules
582        .into_iter()
583        .zip(results)
584        .filter_map(|(module, r)| match r {
585            Ok(doc) => Some(doc),
586            Err(e) => {
587                // 失败隔离:记录失败的模块名(T3.2),不中断其他模块生成
588                tracing::warn!("跳过 Wiki 页面生成 {}: {}", module, e);
589                wiki_gen.record_failure(module);
590                None
591            }
592        })
593        .collect()
594}
595
596/// 全局文档受影响标记(P1-2 全局文档增量:受影响判断)
597///
598/// 增量模式按信号决定是否重生成全局文档,未受影响时从导出快照回填
599/// 旧文档(零 LLM 成本)。全量模式恒为全受影响。
600#[derive(Debug, Clone, Default)]
601pub struct GlobalDocAffected {
602    /// 架构概览/项目概览:接口级实体变化(新增/删除/签名)才受影响
603    pub architecture: bool,
604    /// 数据库 Schema 文档:本次变更含 .sql 文件才受影响
605    pub schema: bool,
606}
607
608impl GlobalDocAffected {
609    /// 全受影响(全量生成路径)
610    pub fn all() -> Self {
611        Self { architecture: true, schema: true }
612    }
613}
614
615/// 生成与具体模块无关的全局文档(架构概览 + 项目概览 + 数据库 Schema),追加到 `documents`
616///
617/// 全量与增量两条生成路径共用,避免复制相同的调用逻辑(DRY)。
618/// 这三类文档反映全仓库状态:架构概览与项目概览基于完整 KnowledgeGraph 的模块列表,
619/// Schema 文档基于全量 .sql 文件,与"本次变更了哪些模块"无关,
620/// 因此增量路径也必须重新生成,否则增量输出会比全量输出缺少这三类页面。
621/// 全局文档生成(架构概览 + 项目概览 + 数据库 Schema)
622///
623/// 参数为生成上下文的完整输入集(7 个):wiki_gen 与 provider 是两条独立
624/// LLM 通道(页面 vs 全局文档)、graph/config/root/cards 是生成所需的
625/// 图结构、配置、项目根与卡片摘要、documents 是输出累加器。
626/// 引入上下文结构体需新增类型仅服务本函数两处调用,YAGNI——保留平铺
627/// 参数并在此说明,属明确的例外。
628#[allow(clippy::too_many_arguments)]
629async fn generate_global_documents(
630    wiki_gen: &WikiGenerator<'_, Provider>,
631    provider: &Provider,
632    graph: &KnowledgeGraph,
633    config: &WikiConfig,
634    root: &crate::project::ProjectRoot,
635    cards: &[KnowledgeCard],
636    documents: &mut Vec<WikiDocument>,
637    affected: &GlobalDocAffected,
638    has_deleted_files: bool,
639) -> Result<()> {
640    // 文档类型决策:DocumentKind 是纯枚举(无 architecture 等可复用字段),
641    // 且 output::wiki_page_path 按 kind 特判文件名(架构概览→architecture.md,
642    // 项目概览→overview.md),因此新增 ProjectOverview 变体而非复用
643    // ArchitectureOverview——复用会把概览写进 architecture.md,路径语义错位。
644    if affected.architecture {
645        // 架构概览与项目概览:没有卡片(本次没有模块被生成)时跳过,避免对空仓库发无意义的 LLM 调用
646        // ——但纯删除场景例外(has_deleted_files):删除属接口级变化,即使本次没有
647        // 模块被重生成(孤立文件全删),架构/概览也必须重生成,否则回填旧版继续
648        // 列出已删模块(v21 验证轮修复)。
649        if !cards.is_empty() || has_deleted_files {
650            // generate_architecture / generate_overview 需要 GenerationOutput 快照(内部只用 cards 构建引用列表)
651            let output_snapshot = GenerationOutput {
652                cards: cards.to_vec(),
653                documents: documents.clone(),
654                generation_stats: GenerationStats::default(),
655                timings: crate::GenerationTimings::default(),
656            };
657            match wiki_gen
658                .generate_architecture(&output_snapshot, graph, config, root)
659                .await
660            {
661                Ok(arch) => documents.push(arch),
662                // U06/D12:provider 瞬时失败不再丢页——降级为确定性骨架
663                //(模块/依赖清单,零 LLM),下次成功生成时补齐摘要
664                Err(e) => {
665                    tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
666                    documents.push(crate::generate::wiki::fallback_architecture_doc(
667                        graph,
668                        config,
669                        crate::model::DocumentKind::ArchitectureOverview,
670                        "架构概览",
671                    ));
672                }
673            }
674            match wiki_gen
675                .generate_overview(&output_snapshot, graph, config, root)
676                .await
677            {
678                Ok(overview) => documents.push(overview),
679                Err(e) => {
680                    tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
681                    documents.push(crate::generate::wiki::fallback_architecture_doc(
682                        graph,
683                        config,
684                        crate::model::DocumentKind::ProjectOverview,
685                        "项目概览",
686                    ));
687                }
688            }
689        }
690    } else if !backfill_global_docs(config, documents, &[
691        crate::model::DocumentKind::ArchitectureOverview,
692        crate::model::DocumentKind::ProjectOverview,
693    ]) {
694        // 快照不可用(首次增量/快照损坏)→ 回退生成,保证页面存在性
695        tracing::info!("全局文档快照回填不可用,回退重新生成");
696        let output_snapshot = GenerationOutput {
697            cards: cards.to_vec(),
698            documents: documents.clone(),
699            generation_stats: GenerationStats::default(),
700            timings: crate::GenerationTimings::default(),
701        };
702        match wiki_gen
703            .generate_architecture(&output_snapshot, graph, config, root)
704            .await
705        {
706            Ok(arch) => documents.push(arch),
707            // U06/D12:同 affected 路径——失败降级为确定性骨架而非丢页
708            Err(e) => {
709                tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
710                documents.push(crate::generate::wiki::fallback_architecture_doc(
711                    graph,
712                    config,
713                    crate::model::DocumentKind::ArchitectureOverview,
714                    "架构概览",
715                ));
716            }
717        }
718        match wiki_gen
719            .generate_overview(&output_snapshot, graph, config, root)
720            .await
721        {
722            Ok(overview) => documents.push(overview),
723            Err(e) => {
724                tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
725                documents.push(crate::generate::wiki::fallback_architecture_doc(
726                    graph,
727                    config,
728                    crate::model::DocumentKind::ProjectOverview,
729                    "项目概览",
730                ));
731            }
732        }
733    }
734
735    // 数据库 Schema 文档:无 .sql 文件时内部直接返回空列表,不调用 LLM
736    if affected.schema {
737        match schema::generate_schema_documents_at(root, provider, config).await {
738            Ok(mut schema_docs) => documents.append(&mut schema_docs),
739            Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
740        }
741    } else if !backfill_global_docs(config, documents, &[crate::model::DocumentKind::DatabaseSchema]) {
742        tracing::info!("Schema 快照回填不可用,回退重新生成");
743        match schema::generate_schema_documents_at(root, provider, config).await {
744            Ok(mut schema_docs) => documents.append(&mut schema_docs),
745            Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
746        }
747    }
748
749    Ok(())
750}
751
752/// 从导出快照回填指定类型的全局文档到 `documents`(P1-2 全局文档增量)
753///
754/// 快照是 render_all 每次写盘后的产物快照(.state/export_snapshot.json),
755/// 内含完整 WikiDocument 对象。未受影响的全局文档从快照回填:
756/// 渲染幂等(内容与上次一致 → 指纹一致 → 不误判人工修改)、不触发 LLM、
757/// 且路径仍在 rendered_paths 中(不被陈旧清理误删)。
758/// 返回是否至少回填一个(快照缺失/损坏/无该类文档 → false,调用方回退生成)。
759///
760/// 语言一致性:快照文档语言是上次生成时的配置语言,若当前配置
761/// `wiki.language` 已切换(如 zh→en),回填的旧语言文档会写进旧语言
762/// 目录,新语言目录缺失该页——视为受影响(不匹配即不回填),
763/// 由调用方回退到新语言的 LLM 生成。
764pub(crate) fn backfill_global_docs(
765    config: &WikiConfig,
766    documents: &mut Vec<WikiDocument>,
767    kinds: &[crate::model::DocumentKind],
768) -> bool {
769    let snapshot_path = crate::output::export_snapshot_path(config.output_dir());
770    let Ok(content) = std::fs::read_to_string(&snapshot_path) else {
771        return false;
772    };
773    let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content) else {
774        tracing::warn!("导出快照解析失败(将回退重新生成全局文档): {}", snapshot_path.display());
775        return false;
776    };
777    let mut filled = false;
778    for doc in snapshot.documents {
779        if kinds.contains(&doc.kind)
780            // 语言一致性:快照语言 ≠ 当前主语言 → 语言配置已切换,
781            // 旧语言内容不能回填(写盘目录错位),回退生成
782            && doc.language == config.wiki.language
783            // 去重锚定 title(写盘路径由 title 派生)而非 kind:Schema 文档
784            // 按 .sql 文件每份(title 含路径),按 kind 去重会把多份同名
785            // kind 的其余页丢弃 → cleanup 差集误删磁盘上的其余 schema 页
786            && !documents.iter().any(|d| d.title == doc.title && d.language == doc.language)
787        {
788            documents.push(doc);
789            filled = true;
790        }
791    }
792    filled
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::model::DocumentKind;
799
800    /// 构造指定标题的 WikiDocument(测试辅助,其余字段留空)
801    fn make_document(title: &str) -> WikiDocument {
802        WikiDocument {
803            title: title.into(),
804            kind: DocumentKind::WikiPage,
805            content: String::new(),
806            language: "zh".into(),
807            module_path: vec![],
808            references: vec![],
809            last_updated: String::new(),
810            based_on_commit: None,
811            fingerprint: None,
812        }
813    }
814
815    /// P1-2:导出快照回填——未受影响的全局文档从快照恢复,且不与
816    /// 本次已生成文档重复(同一类型只保留一个)
817    #[test]
818    fn test_backfill_global_docs_from_snapshot() {
819        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_{}", std::process::id()));
820        let _ = std::fs::remove_dir_all(&dir);
821        std::fs::create_dir_all(dir.join(".state")).unwrap();
822
823        let arch = WikiDocument {
824            title: "架构概览".into(),
825            kind: DocumentKind::ArchitectureOverview,
826            content: "架构内容".into(),
827            language: "zh".into(),
828            module_path: vec![],
829            references: vec![],
830            last_updated: "2025-01-01T00:00:00Z".into(),
831            based_on_commit: None,
832            fingerprint: None,
833        };
834        let overview = WikiDocument {
835            title: "项目概览".into(),
836            kind: DocumentKind::ProjectOverview,
837            content: "概览内容".into(),
838            language: "zh".into(),
839            module_path: vec![],
840            references: vec![],
841            last_updated: "2025-01-01T00:00:00Z".into(),
842            based_on_commit: None,
843            fingerprint: None,
844        };
845        let snapshot = crate::output::ExportSnapshot {
846            version: 1,
847            documents: vec![arch.clone(), overview.clone()],
848            cards: vec![],
849            modules: vec![],
850        };
851        crate::fs::write_file_atomic(
852            &dir.join(".state").join("export_snapshot.json"),
853            &serde_json::to_string(&snapshot).unwrap(),
854        )
855        .unwrap();
856
857        let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
858
859        // 本次已生成 overview(模拟模块页变化触发概览重生成)→ 只回填架构
860        let mut documents = vec![overview.clone()];
861        let filled = backfill_global_docs(
862            &config,
863            &mut documents,
864            &[DocumentKind::ArchitectureOverview, DocumentKind::ProjectOverview],
865        );
866        assert!(filled, "快照存在时应回填");
867        assert_eq!(documents.len(), 2, "回填架构(概览已存在不重复)");
868        assert_eq!(documents[1].kind, DocumentKind::ArchitectureOverview);
869        assert_eq!(documents[1].content, "架构内容");
870        let _ = std::fs::remove_dir_all(&dir);
871    }
872
873    /// P1-2:快照缺失 → 回填失败(调用方据此回退生成)
874    #[test]
875    fn test_backfill_global_docs_missing_snapshot() {
876        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_miss_{}", std::process::id()));
877        let _ = std::fs::remove_dir_all(&dir);
878        std::fs::create_dir_all(&dir).unwrap();
879
880        let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
881
882        let mut documents = Vec::new();
883        let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
884        assert!(!filled, "快照缺失时回填失败(回退生成)");
885        assert!(documents.is_empty());
886        let _ = std::fs::remove_dir_all(&dir);
887    }
888
889    /// 语言切换(zh→en):快照文档语言与当前配置不一致 → 不回填,
890    /// 调用方回退到新语言的 LLM 生成(旧语言内容写盘目录错位会丢页)
891    #[test]
892    fn test_backfill_global_docs_skips_on_language_mismatch() {
893        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_lang_{}", std::process::id()));
894        let _ = std::fs::remove_dir_all(&dir);
895        std::fs::create_dir_all(&dir).unwrap();
896
897        let mut arch = make_document("架构概览");
898        arch.kind = DocumentKind::ArchitectureOverview;
899        arch.language = "zh".into(); // 快照为旧配置语言
900        let snapshot = crate::output::ExportSnapshot {
901            version: 1,
902            documents: vec![arch],
903            cards: vec![],
904            modules: vec![],
905        };
906        crate::fs::write_file_atomic(
907            &dir.join(".state").join("export_snapshot.json"),
908            &serde_json::to_string(&snapshot).unwrap(),
909        )
910        .unwrap();
911
912        let config = WikiConfig {
913            output_dir: Some(dir.clone()),
914            wiki: crate::config::schema::WikiSection { language: "en".into(), guide: Default::default() },
915            ..Default::default()
916        };
917
918        let mut documents = Vec::new();
919        let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
920        assert!(!filled, "语言不匹配时不得回填(回退生成新语言内容)");
921        assert!(documents.is_empty());
922        let _ = std::fs::remove_dir_all(&dir);
923    }
924
925    /// P1-2:受影响判断——接口级实体变化 → 架构受影响;纯 .sql 变更 → 仅 schema 受影响
926    #[test]
927    fn test_global_affected_signal() {
928        use crate::incremental::change::{EntityChange, EntityChangeKind};
929
930        let mut changes = Vec::new();
931        changes.push(EntityChange {
932            file: std::path::PathBuf::from("src/a.rs"),
933            entity_name: "foo".into(),
934            kind: EntityChangeKind::BodyChanged,
935            old_range: None,
936            new_range: None,
937        });
938        let affected = GlobalDocAffected {
939            architecture: crate::incremental::change::EntityChangeSet { changes: changes.clone() }.has_interface_change(),
940            schema: false,
941        };
942        assert!(!affected.architecture, "纯实现级变化不应触发架构重生成");
943
944        changes.push(EntityChange {
945            file: std::path::PathBuf::from("src/a.rs"),
946            entity_name: "bar".into(),
947            kind: EntityChangeKind::Added,
948            old_range: None,
949            new_range: None,
950        });
951        let affected2 = GlobalDocAffected {
952            architecture: crate::incremental::change::EntityChangeSet { changes }.has_interface_change(),
953            schema: false,
954        };
955        assert!(affected2.architecture, "接口级变化应触发架构重生成");
956    }
957
958    /// P1 回归:Schema 文档按 .sql 文件每份(title 含路径),回填去重必须
959    /// 锚定 title+language 而非 kind——按 kind 去重会把多份 schema 页丢弃,
960    /// cleanup 差集随后误删磁盘上的其余 schema 页。
961    #[test]
962    fn test_backfill_global_docs_dedup_by_title_not_kind() {
963        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_schema_{}", std::process::id()));
964        let _ = std::fs::remove_dir_all(&dir);
965        std::fs::create_dir_all(&dir).unwrap();
966
967        let schema_a = WikiDocument {
968            title: "Database Schema: db/a.sql".into(),
969            kind: DocumentKind::DatabaseSchema,
970            content: "A 表结构".into(),
971            language: "zh".into(),
972            module_path: vec![],
973            references: vec![],
974            last_updated: "2025-01-01T00:00:00Z".into(),
975            based_on_commit: None,
976            fingerprint: None,
977        };
978        let schema_b = WikiDocument {
979            title: "Database Schema: db/b.sql".into(),
980            kind: DocumentKind::DatabaseSchema,
981            content: "B 表结构".into(),
982            language: "zh".into(),
983            module_path: vec![],
984            references: vec![],
985            last_updated: "2025-01-01T00:00:00Z".into(),
986            based_on_commit: None,
987            fingerprint: None,
988        };
989        let snapshot = crate::output::ExportSnapshot {
990            version: 1,
991            documents: vec![schema_a.clone(), schema_b.clone()],
992            cards: vec![],
993            modules: vec![],
994        };
995        crate::fs::write_file_atomic(
996            &dir.join(".state").join("export_snapshot.json"),
997            &serde_json::to_string(&snapshot).unwrap(),
998        )
999        .unwrap();
1000
1001        let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };
1002
1003        let mut documents = Vec::new();
1004        let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::DatabaseSchema]);
1005        assert!(filled, "快照存在时应回填");
1006        assert_eq!(
1007            documents.len(),
1008            2,
1009            "两份 schema 文档都应回填(按 title 去重,非按 kind)"
1010        );
1011        let _ = std::fs::remove_dir_all(&dir);
1012    }
1013
1014    /// P1-4 回归:entity-coverage 的签名实体名提取——`pub fn foo(x: i32)` 应提取
1015    /// foo(跳过 pub/fn 关键字),裸名 `Foo` 提取 Foo,与 api.md 权威口径一致。
1016    #[test]
1017    fn test_entity_name_from_signature() {
1018        use crate::output::lint::entity_name_from_signature;
1019        assert_eq!(entity_name_from_signature("pub fn foo(x: i32) -> u32").as_deref(), Some("foo"));
1020        assert_eq!(entity_name_from_signature("fn main()").as_deref(), Some("main"));
1021        assert_eq!(entity_name_from_signature("def bar()").as_deref(), Some("bar"));
1022        assert_eq!(entity_name_from_signature("func Baz()").as_deref(), Some("Baz"));
1023        assert_eq!(entity_name_from_signature("Foo").as_deref(), Some("Foo"));
1024        assert_eq!(entity_name_from_signature("pub struct Alpha").as_deref(), Some("Alpha"));
1025        assert_eq!(entity_name_from_signature(""), None);
1026        assert_eq!(entity_name_from_signature("   "), None);
1027    }
1028}
1029
1030    /// U06/D12:确定性骨架——模块名/实体数/依赖清单全部来自图,零 LLM;
1031    /// references 指向模块页且按标题字典序(确定性输出)
1032    #[test]
1033    fn test_fallback_architecture_doc_skeleton() {
1034        use crate::model::{CodeNode, EdgeKind, NodeKind};
1035        use petgraph::stable_graph::StableDiGraph;
1036
1037        let mut g = StableDiGraph::<CodeNode, crate::model::CodeEdge>::new();
1038        let a = g.add_node(CodeNode {
1039            id: crate::model::NodeId::new(0),
1040            kind: NodeKind::Function,
1041            name: "a_fn".into(),
1042            file_path: Some("src/a.rs".into()),
1043            line_range: None,
1044            doc_comment: None,
1045            signature: None, visibility: None,
1046            module_path: vec!["net".into()],
1047        });
1048        let b = g.add_node(CodeNode {
1049            id: crate::model::NodeId::new(1),
1050            kind: NodeKind::Function,
1051            name: "b_fn".into(),
1052            file_path: Some("src/b.rs".into()),
1053            line_range: None,
1054            doc_comment: None,
1055            signature: None, visibility: None,
1056            module_path: vec!["http".into()],
1057        });
1058        g.add_edge(a, b, crate::model::CodeEdge {
1059            id: petgraph::stable_graph::EdgeIndex::new(0),
1060            kind: EdgeKind::Calls,
1061            source: a,
1062            target: b,
1063            weight: 1.0,
1064            location: None,
1065        });
1066        let graph = crate::model::KnowledgeGraph {
1067            graph: g,
1068            modules: vec![
1069                crate::model::ModuleCluster {
1070                    name: "net".into(),
1071                    node_ids: vec![a],
1072                    cohesion: 1.0,
1073                    coupling: 0.0,
1074                    description: None,
1075                },
1076                crate::model::ModuleCluster {
1077                    name: "http".into(),
1078                    node_ids: vec![b],
1079                    cohesion: 1.0,
1080                    coupling: 0.0,
1081                    description: None,
1082                },
1083            ],
1084            features: Vec::new(),
1085        };
1086
1087        let config = WikiConfig::default();
1088        let doc = crate::generate::wiki::fallback_architecture_doc(
1089            &graph,
1090            &config,
1091            crate::model::DocumentKind::ArchitectureOverview,
1092            "架构概览",
1093        );
1094        assert!(doc.content.contains("架构概览"), "应含标题: {}", doc.content);
1095        assert!(doc.content.contains("net`(1 个实体)"), "应含模块与实体数");
1096        assert!(doc.content.contains("http`(1 个实体)"), "应含模块与实体数");
1097        assert!(doc.content.contains("依赖 http"), "net 应列出依赖 http");
1098        assert_eq!(doc.kind, crate::model::DocumentKind::ArchitectureOverview);
1099        // references 覆盖全部模块且按标题字典序
1100        let titles: Vec<&str> = doc.references.iter().map(|r| r.target_title.as_str()).collect();
1101        assert_eq!(titles, vec!["http", "net"], "references 应按标题字典序: {titles:?}");
1102        assert!(doc.references.iter().all(|r| r.target_path.starts_with("wiki/zh/")), "references 应指向主语言模块页");
1103    }