code-repo-wiki 0.4.0

自动分析代码仓库结构,通过 LLM 生成结构化项目文档(Code Repo Wiki)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
pub mod card;
pub mod chunk;
pub mod embed;
pub mod index;
pub mod llm;
pub mod prompt;
pub mod schema;
pub mod wiki;

use std::collections::HashMap;
use std::path::Path;
use std::time::Instant;

use anyhow::Result;

use crate::config::schema::WikiConfig;
use crate::ingest::parser::FileInsight;
use crate::model::{KnowledgeCard, KnowledgeGraph, WikiDocument};

use self::card::CardGenerator;
use self::chunk::Chunk;
use self::llm::{AnthropicProvider, LlmProvider, OpenAiProvider, Provider};
use self::wiki::WikiGenerator;

/// 生成流水线的输出
pub struct GenerationOutput {
    pub cards: Vec<KnowledgeCard>,
    pub documents: Vec<WikiDocument>,
    pub generation_stats: GenerationStats,
    /// v32 8.1:分块/卡片/Wiki 页三段的内部计时(毫秒)——上层
    /// run_pipeline_with_progress 收集后落盘供 bench 回放剖析
    pub timings: crate::GenerationTimings,
}

/// 生成统计信息
#[derive(Debug, Clone, Default)]
pub struct GenerationStats {
    pub total_tokens_used: usize,
    pub llm_calls: usize,
    pub generation_time_ms: u64,
    /// 生成失败的模块名列表(演进计划 T3.2 失败隔离的可见性出口)
    pub failed_modules: Vec<String>,
}

/// 根据配置创建 LLM Provider(v17 t02:协议按 provider 类型显式绑定)
pub fn create_provider(config: &WikiConfig) -> Result<Provider> {
    match config.llm.provider {
        // openai = OpenAI Responses API 协议(base_url 可配,DeepSeek 归此)
        crate::config::schema::LlmProviderType::OpenAI => {
            Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Responses)?))
        }
        crate::config::schema::LlmProviderType::Anthropic => {
            Ok(Provider::Anthropic(AnthropicProvider::new(&config.llm)?))
        }
        // openai-compatible = chat/completions 协议(custom 并入,v17 t02)
        crate::config::schema::LlmProviderType::OpenAiCompatible => {
            Ok(Provider::OpenAi(OpenAiProvider::new(&config.llm, crate::generate::llm::OpenAiProtocol::Chat)?))
        }
        crate::config::schema::LlmProviderType::Mock => {
            // 本地模拟:测试/CI/无 API Key 场景,返回固定文本
            Ok(Provider::Mock(crate::generate::llm::MockProvider::new()))
        }
    }
}

/// 运行完整的生成流水线
///
/// 1. AST 感知分块(按模块分组)
/// 2. 并行生成 Knowledge Card
/// 3. 串行生成 Wiki 页面(依赖前序卡片摘要)
/// 4. 生成架构概览页面
///
/// extra_edits:本次运行新检测到的人工修改记录(模块名 → 记录文本),
/// 生成卡片前注入 LLM 输入(见 CardGenerator::generate_all_cards);
/// 由上层(lib.rs)从状态指纹比对结果组装,无人工修改时传空表。
/// v32 9.2:按 [wiki.guide] 过滤与排序 chunk 列表(生成引导)。
///
/// - `pages` 非空时仅保留模块路径前缀匹配任一条目的 chunk(未匹配模块
///   不生成独立页,但 overview/架构等全局文档仍全量汇总不受影响)。
///   条目分隔符兼容 `/`、`::`、`\`(如 `src/net` 与 `src::net` 等价),
///   前缀比较按模块名分段(`src/net` 匹配模块 `src::net::tcp`)。
/// - `strict_empty=true`(全量路径):过滤后为空会显式报错——避免用户
///   pages 配置笔误导致「以为生成了实际没有」的静默失败。增量路径
///   (`strict_empty=false`)中受影响模块都不在白名单属正常空集(无
///   页面需更新),记录日志后返回空,不报错。
/// - `priority` 按条目顺序稳定排序(前缀匹配的模块前置),未匹配模块
///   保持原顺序;排序只影响生成顺序,不改变产物内容。
fn filter_chunks_by_guide(
    chunks: Vec<Chunk>,
    guide: &crate::config::schema::WikiGuideSection,
    strict_empty: bool,
) -> Result<Vec<Chunk>> {
    if guide.pages.is_empty() {
        return Ok(chunks);
    }
    let original_len = chunks.len();
    let mut filtered: Vec<Chunk> = chunks
        .into_iter()
        .filter(|c| guide.pages.iter().any(|p| guide_prefix_match(&c.module_path, p)))
        .collect();
    if filtered.is_empty() && original_len > 0 {
        if strict_empty {
            anyhow::bail!(
                "[wiki.guide].pages 未匹配任何模块(共 {} 个模块),请检查 pages 配置",
                original_len
            );
        }
        tracing::info!("增量生成: 受影响模块均不在 [wiki.guide].pages 白名单,跳过生成");
    }
    if !guide.priority.is_empty() {
        filtered.sort_by_key(|c| {
            guide
                .priority
                .iter()
                .position(|p| guide_prefix_match(&c.module_path, p))
                .unwrap_or(usize::MAX)
        });
    }
    Ok(filtered)
}

/// [wiki.guide] 前缀匹配:把 pattern 按 `/`/`::`/`\` 拆成段,与模块路径
/// 段(Vec<String>,来自模块名 split("::"))做前缀比较。
fn guide_prefix_match(module_path: &[String], pattern: &str) -> bool {
    let pat: Vec<&str> = pattern
        .split(['/', ':', '\\'])
        .filter(|s| !s.is_empty())
        .collect();
    if pat.is_empty() {
        return false;
    }
    module_path
        .iter()
        .take(pat.len())
        .map(|s| s.as_str())
        .eq(pat.iter().copied())
}

pub async fn run_generation(
    graph: &KnowledgeGraph,
    insights: &[FileInsight],
    config: &WikiConfig,
    root: &crate::project::ProjectRoot,
    extra_edits: &HashMap<String, Vec<String>>,
) -> Result<GenerationOutput> {
    let start = Instant::now();
    // v32 8.1:三段内部计时(chunk/card/wiki)
    let chunk_start = Instant::now();

    // 1. AST 感知分块
    let chunks = if graph.modules.is_empty() {
        tracing::warn!("未检测到模块聚类,回退到文件级分块");
        insights
            .iter()
            .map(chunk::chunk_by_file)
            .collect::<Vec<_>>()
    } else {
        chunk::chunk_by_module(insights, &graph.modules, graph)
    };
    // v31 修复(C-03):分块后统一剔除空 chunk——chunk_by_module 对全部模块
    // 产 chunk,增量只喂变更文件时未变更模块 chunk 为空;空 chunk 是确定性
    // 「无内容」而非生成失败,若放行会在生成循环里被空块 bail 记入
    // failed_modules(毒化 should_skip_noop 并引发无关模块补偿重试),且
    // 过滤必须发生在管线入口,保证 chunks/cards/wiki/backfill 全链路 1:1 对齐。
    let chunks: Vec<_> = chunks
        .into_iter()
        .filter(|c| !c.is_empty())
        .collect();
    // v32 9.2:生成引导过滤(全量路径——空匹配显式报错,见 filter 注释)
    let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, true)?;
    tracing::info!("生成进度: 30% - 分块完成,共 {} 个块", chunks.len());
    let chunk_ms = chunk_start.elapsed().as_millis() as u64;

    // 2. 创建 LLM Provider
    let provider = create_provider(config)?;

    // 3. 并行生成 Knowledge Card
    let card_start = Instant::now();
    let card_gen = CardGenerator::new(
        &provider,
        config.clone(),
        crate::config::schema::LLM_MAX_CONCURRENT,
        config.wiki.language.clone(),
    );
    let mut cards = card_gen
        .generate_all_cards(&chunks, extra_edits)
        .await?;
    // 特征追溯回填(演进计划 T3.3):模块实体与特征实体的交集 → 特征名
    backfill_features(&mut cards, &chunks, graph);
    tracing::info!("生成进度: 60% - 知识卡片生成完成,共 {} 个卡片", cards.len());
    let card_ms = card_start.elapsed().as_millis() as u64;

    // 4. 按语言独立生成 Wiki 页面(并行,演进计划 T3.1;卡片仅主语言生成一次,
    // 各语言页面复用主语言卡片摘要;语言列表在 generate_wiki_pages 内部计算)
    let wiki_start = Instant::now();
    let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
    let mut documents =
        generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
    tracing::info!("生成进度: 90% - Wiki 页面生成完成,共 {} 个页面", documents.len());
    let wiki_ms = wiki_start.elapsed().as_millis() as u64;

    // 5. 生成全局文档(架构概览 + 数据库 Schema,全量/增量共用同一辅助函数)
    generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &GlobalDocAffected::all(), false).await?;

    let elapsed = start.elapsed();
    let stats = GenerationStats {
        llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
        generation_time_ms: elapsed.as_millis() as u64,
        // 失败隔离统计(T3.2):卡片与页面两路失败模块名合并
        failed_modules: {
            let mut f = card_gen.failed_modules();
            f.extend(wiki_gen.failed_modules());
            f
        },
        ..Default::default()
    };

    Ok(GenerationOutput {
        cards,
        documents,
        generation_stats: stats,
        timings: crate::GenerationTimings {
            chunk_ms,
            card_ms,
            wiki_ms,
            ..Default::default()
        },
    })
}

/// 增量更新的过滤生成流水线
///
/// 与 `run_generation` 类似,但仅处理 `inc`(增量分析结果)中列出的变更
/// 文件 + 语义传播判定的受影响模块,用于增量更新场景。未变更的文件
/// 使用已有缓存,不触发新的 LLM 调用。
/// extra_edits 语义同 run_generation(本次新检测的人工修改记录)。
pub async fn run_generation_filtered(
    graph: &KnowledgeGraph,
    insights: &[FileInsight],
    config: &WikiConfig,
    root: &crate::project::ProjectRoot,
    inc: &crate::incremental::IncrementalResult,
    extra_edits: &HashMap<String, Vec<String>>,
) -> Result<GenerationOutput> {
    let start = Instant::now();
    let changed_files = &inc.changed_files;
    let entity_changes = &inc.entity_changes;
    let affected_modules = &inc.affected_modules;
    // v32 8.1:三段内部计时
    let chunk_start = Instant::now();

    // 过滤出变更文件的 Insight(克隆为拥有数据)。
    // T2 传播闭环接线:除变更文件外,语义传播判定的受影响模块文件也
    // 并入生成范围——签名/删除等接口级变化会重生成依赖方模块的文档,
    // 实现级变化(body-only)传播结果只含本模块,行为不变。
    let affected_files = crate::incremental::impact::module_files(affected_modules, graph);
    // v23 A1 实体级分类:从生成范围排除「无实体变更」文件——git diff 报告了
    // 变化(文件在 changed_files),但实体级分类(change.rs 三元组全等判定)
    // 未产出任何该文件的变更记录(纯注释/空白/换行符变化)。
    // added/deleted 文件必有 Added/Removed 记录、接口级变化必有记录,故
    // 无记录 = 仅非实体文本变化,模块页无需重生成(旧产物内容与行号引用
    // 均仍准确)。排除后落入下方空集分支走快照回填(零 LLM 保留旧产物)。
    // 与 incremental/mod.rs 的传播起点剔除共用同一函数,保证两处同口径。
    let no_entity_change_files = crate::incremental::change::no_entity_change_files(
        changed_files,
        entity_changes,
        root,
    );
    let mut changed_insights: Vec<FileInsight> = insights
        .iter()
        .filter(|f| {
            (changed_files.contains(&f.path) || affected_files.contains(&f.path))
                && !no_entity_change_files.contains(&f.path)
        })
        .cloned()
        .collect();

    // 纯删除场景的模块级补偿(v21 验证轮起,此处补全 mixed 场景):
    // 被删文件所属模块(快照卡片 related_files 含被删文件且仍有存活
    // 文件的卡片 = 部分删除模块)的存活文件并入变更集,走正常重生成
    // 清除被删实体的页面残留。
    //
    // 补偿必须独立于下方空集回填分支执行:删除与修改并存(mixed)时
    // changed_insights 非空,回填分支不进入——而语义传播的起点(被删
    // 文件)在当前图中无节点(impact.rs find_start_nodes 找不到即跳过),
    // 其模块永远进不了 affected_modules,不显式并入则模块页残留旧内容。
    // 纯删除场景由本逻辑并入后同样落入正常生成路径;快照缺失/损坏时
    // 跳过补偿(下方回填分支对快照失败有全量回退兜底,不丢数据)。
    // 模块归属沿用快照 cards.related_files(与 v22 失败补偿同源机制)。
    let deleted_files: std::collections::HashSet<std::path::PathBuf> = changed_files
        .iter()
        .filter(|f| !root.path().join(f).exists())
        .cloned()
        .collect();
    let surviving_files: std::collections::HashSet<std::path::PathBuf> = if deleted_files.is_empty() {
        std::collections::HashSet::new()
    } else if let Ok(content) =
        std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
        && let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
    {
        snapshot
            .cards
            .iter()
            .filter(|c| {
                !c.related_files.is_empty()
                    && c.related_files.iter().any(|f| deleted_files.contains(Path::new(f)))
                    && c.related_files.iter().any(|f| root.path().join(f).exists())
            })
            .flat_map(|c| c.related_files.iter().map(std::path::PathBuf::from))
            .collect()
    } else {
        std::collections::HashSet::new()
    };
    if !surviving_files.is_empty() {
        let mut present: std::collections::HashSet<std::path::PathBuf> =
            changed_insights.iter().map(|i| i.path.clone()).collect();
        let mut merged = 0usize;
        for insight in insights {
            if surviving_files.contains(&insight.path) && present.insert(insight.path.clone()) {
                changed_insights.push(insight.clone());
                merged += 1;
            }
        }
        if merged > 0 {
            tracing::info!(
                "增量生成: 删除文件所属模块的 {} 个存活文件并入变更集,重生成清除被删实体残留",
                merged
            );
        }
    }

    if changed_insights.is_empty() {
        // 空集场景(v23 A1 起含「无实体变更」文件:纯空白/注释/换行符变化
        // 被实体级分类排除;v21 验证轮起含「整模块全删」:删除补偿未命中
        // 任何部分删除模块):changed_files 非空但无文件命中影响集时,旧实现
        // 直接返回空输出 → render_all 不写任何产物 → cleanup_stale_outputs
        // 差集语义把**全部**旧产物清空(无关模块页也被删)。
        // 修复:从导出快照回填未删除模块的旧产物(零 LLM 成本);
        // 快照缺失(异常)时回退全量生成,宁可多生成也不丢数据。
        if let Ok(content) = std::fs::read_to_string(crate::output::export_snapshot_path(config.output_dir()))
            && let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content)
        {
            // 快照回填:仅剔除整模块全删(related_files 全部不存在)的卡片与
            // 文档——部分删除模块的存活文件已在上方并入变更集走重生成,此处
            // 到达的只有「真无变更可生成」的文件,原样回填旧产物。
            let deleted_modules: std::collections::HashSet<String> = snapshot
                .cards
                .iter()
                .filter(|c| {
                    !c.related_files.is_empty()
                        && c.related_files.iter().all(|f| !root.path().join(f).exists())
                })
                .map(|c| c.module_name.clone())
                .collect();
            let cards: Vec<KnowledgeCard> = snapshot
                .cards
                .into_iter()
                .filter(|c| !deleted_modules.contains(&c.module_name))
                .collect();
            let documents: Vec<WikiDocument> = snapshot
                .documents
                .into_iter()
                .filter(|d| !deleted_modules.contains(&d.title))
                .collect();
            tracing::info!(
                "增量生成: 空集场景({} 个变更文件),从快照回填 {} 文档 {} 卡片(跳过已删模块 {} 个)",
                changed_files.len(),
                documents.len(),
                cards.len(),
                deleted_modules.len()
            );
            return Ok(GenerationOutput {
                cards,
                documents,
                generation_stats: GenerationStats::default(),
                timings: crate::GenerationTimings::default(),
            });
        } else {
            tracing::warn!("增量生成: 纯删除场景但导出快照缺失,回退全量生成防止产物误清");
            // 回退全量:所有现存文件视为变更,走下方正常生成路径
            changed_insights = insights.to_vec();
        }
    } else {
        tracing::info!("增量生成: {} 个文件变更", changed_insights.len());
    }

    // 1. AST 感知分块(仅变更文件)
    let chunks: Vec<_> = if graph.modules.is_empty() {
        changed_insights
            .iter()
            .map(chunk::chunk_by_file)
            .collect()
    } else {
        // 按模块重新组织变更文件,保持模块上下文
        chunk::chunk_by_module(&changed_insights, &graph.modules, graph)
    };
    // v31 修复(C-03):同全量路径——管线入口剔除空 chunk(增量模式未变更
    // 模块),保证 chunks/cards/wiki/backfill 全链路 1:1 对齐,且空 chunk
    // 不会经空块 bail 污染 failed_modules。
    let chunks: Vec<_> = chunks
        .into_iter()
        .filter(|c| !c.is_empty())
        .collect();
    // v32 9.2:生成引导过滤(增量路径——受影响模块不在白名单=正常空集,
    // 不报错;白名单只约束「是否生成」,不改变增量影响传播判定本身)
    let chunks = filter_chunks_by_guide(chunks, &config.wiki.guide, false)?;
    tracing::info!("增量分块完成: {} 个块", chunks.len());
    let chunk_ms = chunk_start.elapsed().as_millis() as u64;

    // 2. 创建 LLM Provider
    let provider = create_provider(config)?;

    // 3. 并行生成 Knowledge Card(仅变更块)
    let card_start = Instant::now();
    let card_gen = CardGenerator::new(
        &provider,
        config.clone(),
        crate::config::schema::LLM_MAX_CONCURRENT,
        config.wiki.language.clone(),
    );
    let mut cards = card_gen
        .generate_all_cards(&chunks, extra_edits)
        .await?;
    // 特征追溯回填(演进计划 T3.3):模块实体与特征实体的交集 → 特征名
    backfill_features(&mut cards, &chunks, graph);
    let card_ms = card_start.elapsed().as_millis() as u64;

    // 4. 按语言独立生成 Wiki 页面(并行,演进计划 T3.1;仅变更块;卡片仅主语言生成一次,
    // 各语言页面复用主语言卡片摘要)
    let wiki_start = Instant::now();
    let wiki_gen = WikiGenerator::new(&provider, crate::config::schema::LLM_MAX_CONCURRENT);
    let mut documents =
        generate_wiki_pages(&wiki_gen, &chunks, &cards, config, crate::config::schema::LLM_MAX_CONCURRENT, root, &build_entity_ranges(insights)).await;
    let wiki_ms = wiki_start.elapsed().as_millis() as u64;

    // 5. 生成全局文档(架构概览 + 数据库 Schema)
    // P1-2 全局文档增量(受影响判断):架构/概览只在接口级实体变化
    // (新增/删除/签名变更)时重生成——纯实现级(body-only)变化不改变
    // 模块间依赖视图;Schema 只在本次变更含 .sql 文件时重生成。未受影响的
    // 全局文档从导出快照回填旧版(零 LLM 成本,渲染幂等不误判人工修改),
    // 快照不可用时回退生成保证页面存在性。全量路径恒全受影响(all())。
    let global_affected = GlobalDocAffected {
        architecture: entity_changes.has_interface_change(),
        schema: changed_files
            .iter()
            .any(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sql"))),
    };
    generate_global_documents(&wiki_gen, &provider, graph, config, root, &cards, &mut documents, &global_affected, inc.has_deleted_files).await?;

    let elapsed = start.elapsed();
    let stats = GenerationStats {
        llm_calls: card_gen.llm_call_count() + wiki_gen.llm_call_count(),
        generation_time_ms: elapsed.as_millis() as u64,
        // 失败隔离统计(T3.2):卡片与页面两路失败模块名合并
        failed_modules: {
            let mut f = card_gen.failed_modules();
            f.extend(wiki_gen.failed_modules());
            f
        },
        ..Default::default()
    };

    Ok(GenerationOutput {
        cards,
        documents,
        generation_stats: stats,
        timings: crate::GenerationTimings {
            chunk_ms,
            card_ms,
            wiki_ms,
            ..Default::default()
        },
    })
}

/// 从全仓库解析结果构建"相对路径 → 实体行区间列表"表(v14 B 组)
///
/// 供引用区间重叠校验使用(validate_citations_against_entities):
/// 键用 norm_sep 归一化的相对路径(与引用提取的正斜杠形态统一,Windows
/// 下不归一化会恒不命中),值 = 该文件全部实体的 (line_start, line_end)。
///
/// 必须用**全仓库** insights 而非变更文件子集——wiki 页面可能引用模块外
/// 文件(跨模块引用是正常行为),只传变更集会导致模块外引用全部误判
/// 为"无实体文件"而放行(区间校验失效)。
fn build_entity_ranges(insights: &[FileInsight]) -> crate::output::citation::EntityRanges {
    insights
        .iter()
        .map(|insight| {
            let key = crate::incremental::norm_sep(&insight.path.to_string_lossy());
            let ranges: Vec<(usize, usize)> = insight
                .entities
                .iter()
                .map(|e| (e.line_start, e.line_end))
                .collect();
            (key, ranges)
        })
        .collect()
}

/// 特征追溯回填(演进计划 T3.3)
///
/// 模块涉及的实体级特征 = 模块 chunk 实体名与特征实体名集合的交集。
/// 特征名列表写入卡片(render_knowledge_card 渲染"特征追溯"节),
/// 提供"功能 → 实现它的模块"的可追溯视图(RepoSummary 的 traceability)。
/// 特征实体名经 graph 反查 NodeId 得到;不经过 LLM,杜绝幻觉。
fn backfill_features(cards: &mut [KnowledgeCard], chunks: &[Chunk], graph: &KnowledgeGraph) {
    if graph.features.is_empty() || cards.is_empty() {
        return;
    }
    // 预构建 特征名 → 实体名集合(避免每张卡片重复遍历图)
    let feature_entities: Vec<(String, std::collections::HashSet<String>)> = graph
        .features
        .iter()
        .map(|f| {
            let names: std::collections::HashSet<String> = f
                .node_ids
                .iter()
                .filter_map(|nid| graph.graph.node_weight(*nid).map(|n| n.name.clone()))
                .collect();
            (f.name.clone(), names)
        })
        .collect();
    for (card, chunk) in cards.iter_mut().zip(chunks) {
        let entity_names: std::collections::HashSet<&str> =
            chunk.entities.iter().map(|e| e.name.as_str()).collect();
        let mut matched: Vec<String> = feature_entities
            .iter()
            .filter(|(_, names)| names.iter().any(|n| entity_names.contains(n.as_str())))
            .map(|(name, _)| name.clone())
            .collect();
        matched.sort();
        card.features = matched;
    }
}

// 实体摘要生成已删除(v31):原 generate_entity_summaries 对每实体一次
// LLM 调用(全量 1500 实体=1500 次调用),但 Entity.summary 字段零消费者
// (全仓库仅自身写入/过滤读取)——纯 token 浪费。未来如需实体级语义索引,
// 应在生成时预索引重建,而非逐个惰性调用。

/// 按语言并行生成 Wiki 页面(演进计划 T3.1 并行化)
///
/// 卡片摘要按 chunk 索引一一对应;并发受 max_concurrent 信号量控制,
/// join_all 保序收集——与串行版的产出顺序一致,页面集合不变。
/// 失败页面跳过并告警(不中断整体生成)。
async fn generate_wiki_pages<P: LlmProvider>(
    wiki_gen: &WikiGenerator<'_, P>,
    chunks: &[Chunk],
    cards: &[KnowledgeCard],
    config: &WikiConfig,
    max_concurrent: usize,
    root: &crate::project::ProjectRoot,
    entity_ranges: &crate::output::citation::EntityRanges,
) -> Vec<WikiDocument> {
    let languages = crate::output::wiki_languages(config);
    let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(max_concurrent.max(1)));
    let mut handles = Vec::with_capacity(chunks.len() * languages.len());
    // 记录每个任务的模块名(失败时写入 wiki_gen 的失败列表,T3.2)
    let mut task_modules = Vec::with_capacity(chunks.len() * languages.len());
    for lang in &languages {
        let mut lang_cfg = config.clone();
        lang_cfg.wiki.language = lang.clone();
        for (i, chunk) in chunks.iter().enumerate() {
            let card_summary = cards.get(i).map(|c| c.summary.clone()).unwrap_or_default();
            let semaphore = semaphore.clone();
            let lang_cfg = lang_cfg.clone();
            task_modules.push(chunk.module_path.join("::"));
            handles.push(async move {
                let _permit = semaphore
                    .acquire()
                    .await
                    .map_err(|_| anyhow::anyhow!("信号量已关闭"))?;
                wiki_gen
                    .generate_wiki_page(chunk, &card_summary, &lang_cfg, root, Some(entity_ranges))
                    .await
            });
        }
    }

    let results = futures::future::join_all(handles).await;
    task_modules
        .into_iter()
        .zip(results)
        .filter_map(|(module, r)| match r {
            Ok(doc) => Some(doc),
            Err(e) => {
                // 失败隔离:记录失败的模块名(T3.2),不中断其他模块生成
                tracing::warn!("跳过 Wiki 页面生成 {}: {}", module, e);
                wiki_gen.record_failure(module);
                None
            }
        })
        .collect()
}

/// 全局文档受影响标记(P1-2 全局文档增量:受影响判断)
///
/// 增量模式按信号决定是否重生成全局文档,未受影响时从导出快照回填
/// 旧文档(零 LLM 成本)。全量模式恒为全受影响。
#[derive(Debug, Clone, Default)]
pub struct GlobalDocAffected {
    /// 架构概览/项目概览:接口级实体变化(新增/删除/签名)才受影响
    pub architecture: bool,
    /// 数据库 Schema 文档:本次变更含 .sql 文件才受影响
    pub schema: bool,
}

impl GlobalDocAffected {
    /// 全受影响(全量生成路径)
    pub fn all() -> Self {
        Self { architecture: true, schema: true }
    }
}

/// 生成与具体模块无关的全局文档(架构概览 + 项目概览 + 数据库 Schema),追加到 `documents`
///
/// 全量与增量两条生成路径共用,避免复制相同的调用逻辑(DRY)。
/// 这三类文档反映全仓库状态:架构概览与项目概览基于完整 KnowledgeGraph 的模块列表,
/// Schema 文档基于全量 .sql 文件,与"本次变更了哪些模块"无关,
/// 因此增量路径也必须重新生成,否则增量输出会比全量输出缺少这三类页面。
/// 全局文档生成(架构概览 + 项目概览 + 数据库 Schema)
///
/// 参数为生成上下文的完整输入集(7 个):wiki_gen 与 provider 是两条独立
/// LLM 通道(页面 vs 全局文档)、graph/config/root/cards 是生成所需的
/// 图结构、配置、项目根与卡片摘要、documents 是输出累加器。
/// 引入上下文结构体需新增类型仅服务本函数两处调用,YAGNI——保留平铺
/// 参数并在此说明,属明确的例外。
#[allow(clippy::too_many_arguments)]
async fn generate_global_documents(
    wiki_gen: &WikiGenerator<'_, Provider>,
    provider: &Provider,
    graph: &KnowledgeGraph,
    config: &WikiConfig,
    root: &crate::project::ProjectRoot,
    cards: &[KnowledgeCard],
    documents: &mut Vec<WikiDocument>,
    affected: &GlobalDocAffected,
    has_deleted_files: bool,
) -> Result<()> {
    // 文档类型决策:DocumentKind 是纯枚举(无 architecture 等可复用字段),
    // 且 output::wiki_page_path 按 kind 特判文件名(架构概览→architecture.md,
    // 项目概览→overview.md),因此新增 ProjectOverview 变体而非复用
    // ArchitectureOverview——复用会把概览写进 architecture.md,路径语义错位。
    if affected.architecture {
        // 架构概览与项目概览:没有卡片(本次没有模块被生成)时跳过,避免对空仓库发无意义的 LLM 调用
        // ——但纯删除场景例外(has_deleted_files):删除属接口级变化,即使本次没有
        // 模块被重生成(孤立文件全删),架构/概览也必须重生成,否则回填旧版继续
        // 列出已删模块(v21 验证轮修复)。
        if !cards.is_empty() || has_deleted_files {
            // generate_architecture / generate_overview 需要 GenerationOutput 快照(内部只用 cards 构建引用列表)
            let output_snapshot = GenerationOutput {
                cards: cards.to_vec(),
                documents: documents.clone(),
                generation_stats: GenerationStats::default(),
                timings: crate::GenerationTimings::default(),
            };
            match wiki_gen
                .generate_architecture(&output_snapshot, graph, config, root)
                .await
            {
                Ok(arch) => documents.push(arch),
                // U06/D12:provider 瞬时失败不再丢页——降级为确定性骨架
                //(模块/依赖清单,零 LLM),下次成功生成时补齐摘要
                Err(e) => {
                    tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
                    documents.push(crate::generate::wiki::fallback_architecture_doc(
                        graph,
                        config,
                        crate::model::DocumentKind::ArchitectureOverview,
                        "架构概览",
                    ));
                }
            }
            match wiki_gen
                .generate_overview(&output_snapshot, graph, config, root)
                .await
            {
                Ok(overview) => documents.push(overview),
                Err(e) => {
                    tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
                    documents.push(crate::generate::wiki::fallback_architecture_doc(
                        graph,
                        config,
                        crate::model::DocumentKind::ProjectOverview,
                        "项目概览",
                    ));
                }
            }
        }
    } else if !backfill_global_docs(config, documents, &[
        crate::model::DocumentKind::ArchitectureOverview,
        crate::model::DocumentKind::ProjectOverview,
    ]) {
        // 快照不可用(首次增量/快照损坏)→ 回退生成,保证页面存在性
        tracing::info!("全局文档快照回填不可用,回退重新生成");
        let output_snapshot = GenerationOutput {
            cards: cards.to_vec(),
            documents: documents.clone(),
            generation_stats: GenerationStats::default(),
            timings: crate::GenerationTimings::default(),
        };
        match wiki_gen
            .generate_architecture(&output_snapshot, graph, config, root)
            .await
        {
            Ok(arch) => documents.push(arch),
            // U06/D12:同 affected 路径——失败降级为确定性骨架而非丢页
            Err(e) => {
                tracing::warn!("架构概览生成失败,降级为确定性骨架: {e}");
                documents.push(crate::generate::wiki::fallback_architecture_doc(
                    graph,
                    config,
                    crate::model::DocumentKind::ArchitectureOverview,
                    "架构概览",
                ));
            }
        }
        match wiki_gen
            .generate_overview(&output_snapshot, graph, config, root)
            .await
        {
            Ok(overview) => documents.push(overview),
            Err(e) => {
                tracing::warn!("项目概览生成失败,降级为确定性骨架: {e}");
                documents.push(crate::generate::wiki::fallback_architecture_doc(
                    graph,
                    config,
                    crate::model::DocumentKind::ProjectOverview,
                    "项目概览",
                ));
            }
        }
    }

    // 数据库 Schema 文档:无 .sql 文件时内部直接返回空列表,不调用 LLM
    if affected.schema {
        match schema::generate_schema_documents_at(root, provider, config).await {
            Ok(mut schema_docs) => documents.append(&mut schema_docs),
            Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
        }
    } else if !backfill_global_docs(config, documents, &[crate::model::DocumentKind::DatabaseSchema]) {
        tracing::info!("Schema 快照回填不可用,回退重新生成");
        match schema::generate_schema_documents_at(root, provider, config).await {
            Ok(mut schema_docs) => documents.append(&mut schema_docs),
            Err(e) => tracing::warn!("数据库 Schema 文档生成跳过: {}", e),
        }
    }

    Ok(())
}

/// 从导出快照回填指定类型的全局文档到 `documents`(P1-2 全局文档增量)
///
/// 快照是 render_all 每次写盘后的产物快照(.state/export_snapshot.json),
/// 内含完整 WikiDocument 对象。未受影响的全局文档从快照回填:
/// 渲染幂等(内容与上次一致 → 指纹一致 → 不误判人工修改)、不触发 LLM、
/// 且路径仍在 rendered_paths 中(不被陈旧清理误删)。
/// 返回是否至少回填一个(快照缺失/损坏/无该类文档 → false,调用方回退生成)。
///
/// 语言一致性:快照文档语言是上次生成时的配置语言,若当前配置
/// `wiki.language` 已切换(如 zh→en),回填的旧语言文档会写进旧语言
/// 目录,新语言目录缺失该页——视为受影响(不匹配即不回填),
/// 由调用方回退到新语言的 LLM 生成。
pub(crate) fn backfill_global_docs(
    config: &WikiConfig,
    documents: &mut Vec<WikiDocument>,
    kinds: &[crate::model::DocumentKind],
) -> bool {
    let snapshot_path = crate::output::export_snapshot_path(config.output_dir());
    let Ok(content) = std::fs::read_to_string(&snapshot_path) else {
        return false;
    };
    let Ok(snapshot) = serde_json::from_str::<crate::output::ExportSnapshot>(&content) else {
        tracing::warn!("导出快照解析失败(将回退重新生成全局文档): {}", snapshot_path.display());
        return false;
    };
    let mut filled = false;
    for doc in snapshot.documents {
        if kinds.contains(&doc.kind)
            // 语言一致性:快照语言 ≠ 当前主语言 → 语言配置已切换,
            // 旧语言内容不能回填(写盘目录错位),回退生成
            && doc.language == config.wiki.language
            // 去重锚定 title(写盘路径由 title 派生)而非 kind:Schema 文档
            // 按 .sql 文件每份(title 含路径),按 kind 去重会把多份同名
            // kind 的其余页丢弃 → cleanup 差集误删磁盘上的其余 schema 页
            && !documents.iter().any(|d| d.title == doc.title && d.language == doc.language)
        {
            documents.push(doc);
            filled = true;
        }
    }
    filled
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::DocumentKind;

    /// 构造指定标题的 WikiDocument(测试辅助,其余字段留空)
    fn make_document(title: &str) -> WikiDocument {
        WikiDocument {
            title: title.into(),
            kind: DocumentKind::WikiPage,
            content: String::new(),
            language: "zh".into(),
            module_path: vec![],
            references: vec![],
            last_updated: String::new(),
            based_on_commit: None,
            fingerprint: None,
        }
    }

    /// P1-2:导出快照回填——未受影响的全局文档从快照恢复,且不与
    /// 本次已生成文档重复(同一类型只保留一个)
    #[test]
    fn test_backfill_global_docs_from_snapshot() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join(".state")).unwrap();

        let arch = WikiDocument {
            title: "架构概览".into(),
            kind: DocumentKind::ArchitectureOverview,
            content: "架构内容".into(),
            language: "zh".into(),
            module_path: vec![],
            references: vec![],
            last_updated: "2025-01-01T00:00:00Z".into(),
            based_on_commit: None,
            fingerprint: None,
        };
        let overview = WikiDocument {
            title: "项目概览".into(),
            kind: DocumentKind::ProjectOverview,
            content: "概览内容".into(),
            language: "zh".into(),
            module_path: vec![],
            references: vec![],
            last_updated: "2025-01-01T00:00:00Z".into(),
            based_on_commit: None,
            fingerprint: None,
        };
        let snapshot = crate::output::ExportSnapshot {
            version: 1,
            documents: vec![arch.clone(), overview.clone()],
            cards: vec![],
            modules: vec![],
        };
        crate::fs::write_file_atomic(
            &dir.join(".state").join("export_snapshot.json"),
            &serde_json::to_string(&snapshot).unwrap(),
        )
        .unwrap();

        let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };

        // 本次已生成 overview(模拟模块页变化触发概览重生成)→ 只回填架构
        let mut documents = vec![overview.clone()];
        let filled = backfill_global_docs(
            &config,
            &mut documents,
            &[DocumentKind::ArchitectureOverview, DocumentKind::ProjectOverview],
        );
        assert!(filled, "快照存在时应回填");
        assert_eq!(documents.len(), 2, "回填架构(概览已存在不重复)");
        assert_eq!(documents[1].kind, DocumentKind::ArchitectureOverview);
        assert_eq!(documents[1].content, "架构内容");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// P1-2:快照缺失 → 回填失败(调用方据此回退生成)
    #[test]
    fn test_backfill_global_docs_missing_snapshot() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_miss_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };

        let mut documents = Vec::new();
        let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
        assert!(!filled, "快照缺失时回填失败(回退生成)");
        assert!(documents.is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 语言切换(zh→en):快照文档语言与当前配置不一致 → 不回填,
    /// 调用方回退到新语言的 LLM 生成(旧语言内容写盘目录错位会丢页)
    #[test]
    fn test_backfill_global_docs_skips_on_language_mismatch() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_lang_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let mut arch = make_document("架构概览");
        arch.kind = DocumentKind::ArchitectureOverview;
        arch.language = "zh".into(); // 快照为旧配置语言
        let snapshot = crate::output::ExportSnapshot {
            version: 1,
            documents: vec![arch],
            cards: vec![],
            modules: vec![],
        };
        crate::fs::write_file_atomic(
            &dir.join(".state").join("export_snapshot.json"),
            &serde_json::to_string(&snapshot).unwrap(),
        )
        .unwrap();

        let config = WikiConfig {
            output_dir: Some(dir.clone()),
            wiki: crate::config::schema::WikiSection { language: "en".into(), guide: Default::default() },
            ..Default::default()
        };

        let mut documents = Vec::new();
        let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::ArchitectureOverview]);
        assert!(!filled, "语言不匹配时不得回填(回退生成新语言内容)");
        assert!(documents.is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// P1-2:受影响判断——接口级实体变化 → 架构受影响;纯 .sql 变更 → 仅 schema 受影响
    #[test]
    fn test_global_affected_signal() {
        use crate::incremental::change::{EntityChange, EntityChangeKind};

        let mut changes = Vec::new();
        changes.push(EntityChange {
            file: std::path::PathBuf::from("src/a.rs"),
            entity_name: "foo".into(),
            kind: EntityChangeKind::BodyChanged,
            old_range: None,
            new_range: None,
        });
        let affected = GlobalDocAffected {
            architecture: crate::incremental::change::EntityChangeSet { changes: changes.clone() }.has_interface_change(),
            schema: false,
        };
        assert!(!affected.architecture, "纯实现级变化不应触发架构重生成");

        changes.push(EntityChange {
            file: std::path::PathBuf::from("src/a.rs"),
            entity_name: "bar".into(),
            kind: EntityChangeKind::Added,
            old_range: None,
            new_range: None,
        });
        let affected2 = GlobalDocAffected {
            architecture: crate::incremental::change::EntityChangeSet { changes }.has_interface_change(),
            schema: false,
        };
        assert!(affected2.architecture, "接口级变化应触发架构重生成");
    }

    /// P1 回归:Schema 文档按 .sql 文件每份(title 含路径),回填去重必须
    /// 锚定 title+language 而非 kind——按 kind 去重会把多份 schema 页丢弃,
    /// cleanup 差集随后误删磁盘上的其余 schema 页。
    #[test]
    fn test_backfill_global_docs_dedup_by_title_not_kind() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_backfill_schema_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let schema_a = WikiDocument {
            title: "Database Schema: db/a.sql".into(),
            kind: DocumentKind::DatabaseSchema,
            content: "A 表结构".into(),
            language: "zh".into(),
            module_path: vec![],
            references: vec![],
            last_updated: "2025-01-01T00:00:00Z".into(),
            based_on_commit: None,
            fingerprint: None,
        };
        let schema_b = WikiDocument {
            title: "Database Schema: db/b.sql".into(),
            kind: DocumentKind::DatabaseSchema,
            content: "B 表结构".into(),
            language: "zh".into(),
            module_path: vec![],
            references: vec![],
            last_updated: "2025-01-01T00:00:00Z".into(),
            based_on_commit: None,
            fingerprint: None,
        };
        let snapshot = crate::output::ExportSnapshot {
            version: 1,
            documents: vec![schema_a.clone(), schema_b.clone()],
            cards: vec![],
            modules: vec![],
        };
        crate::fs::write_file_atomic(
            &dir.join(".state").join("export_snapshot.json"),
            &serde_json::to_string(&snapshot).unwrap(),
        )
        .unwrap();

        let config = WikiConfig { output_dir: Some(dir.clone()), ..Default::default() };

        let mut documents = Vec::new();
        let filled = backfill_global_docs(&config, &mut documents, &[DocumentKind::DatabaseSchema]);
        assert!(filled, "快照存在时应回填");
        assert_eq!(
            documents.len(),
            2,
            "两份 schema 文档都应回填(按 title 去重,非按 kind)"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// P1-4 回归:entity-coverage 的签名实体名提取——`pub fn foo(x: i32)` 应提取
    /// foo(跳过 pub/fn 关键字),裸名 `Foo` 提取 Foo,与 api.md 权威口径一致。
    #[test]
    fn test_entity_name_from_signature() {
        use crate::output::lint::entity_name_from_signature;
        assert_eq!(entity_name_from_signature("pub fn foo(x: i32) -> u32").as_deref(), Some("foo"));
        assert_eq!(entity_name_from_signature("fn main()").as_deref(), Some("main"));
        assert_eq!(entity_name_from_signature("def bar()").as_deref(), Some("bar"));
        assert_eq!(entity_name_from_signature("func Baz()").as_deref(), Some("Baz"));
        assert_eq!(entity_name_from_signature("Foo").as_deref(), Some("Foo"));
        assert_eq!(entity_name_from_signature("pub struct Alpha").as_deref(), Some("Alpha"));
        assert_eq!(entity_name_from_signature(""), None);
        assert_eq!(entity_name_from_signature("   "), None);
    }
}

    /// U06/D12:确定性骨架——模块名/实体数/依赖清单全部来自图,零 LLM;
    /// references 指向模块页且按标题字典序(确定性输出)
    #[test]
    fn test_fallback_architecture_doc_skeleton() {
        use crate::model::{CodeNode, EdgeKind, NodeKind};
        use petgraph::stable_graph::StableDiGraph;

        let mut g = StableDiGraph::<CodeNode, crate::model::CodeEdge>::new();
        let a = g.add_node(CodeNode {
            id: crate::model::NodeId::new(0),
            kind: NodeKind::Function,
            name: "a_fn".into(),
            file_path: Some("src/a.rs".into()),
            line_range: None,
            doc_comment: None,
            signature: None, visibility: None,
            module_path: vec!["net".into()],
        });
        let b = g.add_node(CodeNode {
            id: crate::model::NodeId::new(1),
            kind: NodeKind::Function,
            name: "b_fn".into(),
            file_path: Some("src/b.rs".into()),
            line_range: None,
            doc_comment: None,
            signature: None, visibility: None,
            module_path: vec!["http".into()],
        });
        g.add_edge(a, b, crate::model::CodeEdge {
            id: petgraph::stable_graph::EdgeIndex::new(0),
            kind: EdgeKind::Calls,
            source: a,
            target: b,
            weight: 1.0,
            location: None,
        });
        let graph = crate::model::KnowledgeGraph {
            graph: g,
            modules: vec![
                crate::model::ModuleCluster {
                    name: "net".into(),
                    node_ids: vec![a],
                    cohesion: 1.0,
                    coupling: 0.0,
                    description: None,
                },
                crate::model::ModuleCluster {
                    name: "http".into(),
                    node_ids: vec![b],
                    cohesion: 1.0,
                    coupling: 0.0,
                    description: None,
                },
            ],
            features: Vec::new(),
        };

        let config = WikiConfig::default();
        let doc = crate::generate::wiki::fallback_architecture_doc(
            &graph,
            &config,
            crate::model::DocumentKind::ArchitectureOverview,
            "架构概览",
        );
        assert!(doc.content.contains("架构概览"), "应含标题: {}", doc.content);
        assert!(doc.content.contains("net`(1 个实体)"), "应含模块与实体数");
        assert!(doc.content.contains("http`(1 个实体)"), "应含模块与实体数");
        assert!(doc.content.contains("依赖 http"), "net 应列出依赖 http");
        assert_eq!(doc.kind, crate::model::DocumentKind::ArchitectureOverview);
        // references 覆盖全部模块且按标题字典序
        let titles: Vec<&str> = doc.references.iter().map(|r| r.target_title.as_str()).collect();
        assert_eq!(titles, vec!["http", "net"], "references 应按标题字典序: {titles:?}");
        assert!(doc.references.iter().all(|r| r.target_path.starts_with("wiki/zh/")), "references 应指向主语言模块页");
    }