code-repo-wiki 0.5.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
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};

use anyhow::Result;

use crate::config::schema::WikiConfig;
use crate::generate::chunk::Chunk;
use crate::generate::llm::{LlmProvider, Message};
use crate::generate::prompt;
use crate::generate::GenerationOutput;
use crate::model::{DocumentKind, EdgeKind, KnowledgeGraph, NodeId, Reference, WikiDocument};

/// Wiki 页面生成器
///
/// 通过 LLM 为每个模块生成叙述性的 Wiki 页面,
/// 供人类开发者阅读和理解代码。
pub struct WikiGenerator<'a, P: LlmProvider> {
    provider: &'a P,
    call_count: AtomicUsize,
    /// 生成失败的模块名列表(演进计划 T3.2 失败隔离:失败只记录不中断)
    failed: std::sync::Mutex<Vec<String>>,
    /// describe_modules 并发信号量(演进计划 T5.1:模块职责描述并行
    /// 生成时限制并发,避免 10+ 模块仓库一次性打爆 LLM API 限流)
    semaphore: std::sync::Arc<tokio::sync::Semaphore>,
    /// 模块职责描述缓存(v31 C-02):内存 memo + 落盘缓存。
    /// 架构概览与项目概览同一轮生成会各调一次 describe_modules——
    /// 内存 memo 消除同一轮内的重复 LLM 调用;落盘缓存按模块内容
    /// 指纹(涉及源文件 SHA256)跨进程复用,增量更新(watch 频繁
    /// 触发)不再对未变模块重复调用 LLM。锁内只做短同步操作
    /// (查/写 HashMap),绝不在持有锁时 await。
    desc_cache: std::sync::Mutex<Option<ModuleDescCache>>,
    /// HEAD 短哈希缓存(v32 10.2):每轮生成只开一次 git 仓库。
    /// None 也可能被缓存(非 git 仓库——生成期间不会变成 git 仓库)。
    head_short: std::sync::OnceLock<Option<String>>,
}

impl<P: LlmProvider> WikiGenerator<'_, P> {
    /// HEAD 短哈希(每轮生成首次调用计算一次,此后复用)
    fn head_short_for(&self, root: &crate::project::ProjectRoot) -> Option<String> {
        self.head_short
            .get_or_init(|| git_head_short(root))
            .clone()
    }
}

/// 模块职责描述缓存条目
///
/// fingerprint = 模块涉及源文件的联合内容指纹(见 module_files_fingerprint);
/// 文件内容未变则指纹一致,描述可直接复用(不触发 LLM 调用)。
#[derive(serde::Serialize, serde::Deserialize)]
struct CacheEntry {
    fingerprint: String,
    description: String,
}

    /// 模块职责描述缓存(v31 C-02)
    ///
    /// 落盘位置 `{output_dir}/.state/module_descriptions.json`(与
    /// generation_state.json 同目录)。加载损坏/缺失时返回空缓存并告警——
    /// 描述按需回退 LLM 重新生成,缓存故障绝不阻断主流程。
    struct ModuleDescCache {
        entries: std::collections::HashMap<String, CacheEntry>,
    }

    /// 读取仓库 HEAD 短哈希(v32 10.2 页面基线行)
    ///
    /// git2 打开失败(非 git 仓库/无 HEAD/权限)一律返回 None——基线行
    /// 是附加信息,任何 git 读取问题都不应中断生成。短哈希取前 8 位,
    /// 与 `git log --oneline` 的默认缩写一致,足够人工核对版本。
    fn git_head_short(root: &crate::project::ProjectRoot) -> Option<String> {
        let repo = git2::Repository::open(root.path()).ok()?;
        let head = repo.head().ok()?;
        let commit = head.peel_to_commit().ok()?;
        let id = commit.id().to_string();
        Some(id[..id.len().min(8)].to_string())
    }

impl ModuleDescCache {
    fn new() -> Self {
        Self {
            entries: std::collections::HashMap::new(),
        }
    }

    /// 从磁盘加载(损坏/缺失→空缓存,调用方决定是否告警)
    fn load(path: &Path) -> Self {
        let Ok(content) = std::fs::read_to_string(path) else {
            return Self::new();
        };
        match serde_json::from_str::<std::collections::HashMap<String, CacheEntry>>(&content) {
            Ok(entries) => Self { entries },
            Err(e) => {
                tracing::warn!(
                    "模块描述缓存解析失败(回退空缓存,按需重新生成): {} {}",
                    path.display(),
                    e
                );
                Self::new()
            }
        }
    }

    /// 原子写盘(temp + rename);失败仅告警不阻断
    fn save(&self, path: &Path) {
        let Ok(content) = serde_json::to_string(&self.entries) else {
            return;
        };
        let Some(parent) = path.parent() else {
            return;
        };
        let _ = std::fs::create_dir_all(parent);
        let tmp = path.with_extension("json.tmp");
        if std::fs::write(&tmp, content).is_ok() {
            let _ = std::fs::rename(&tmp, path);
        }
    }
}

/// 计算模块涉及源文件的联合内容指纹
///
/// 取模块全部实体节点所属文件(去重排序),逐个计算 SHA256 后拼接——
/// 任一文件内容变化都会改变指纹,使该模块的描述缓存失效。
/// 文件不存在/不可读时以 "missing" 占位(该文件变更=指纹变化=重新描述)。
fn module_files_fingerprint(
    module: &crate::model::ModuleCluster,
    graph: &KnowledgeGraph,
    root: &crate::project::ProjectRoot,
) -> String {
    let mut files: Vec<&str> = module
        .node_ids
        .iter()
        .filter_map(|nid| graph.graph.node_weight(*nid))
        .filter_map(|n| n.file_path.as_deref())
        .collect();
    files.sort_unstable();
    files.dedup();
    files
        .iter()
        .map(|f| {
            // 指纹基准=项目根(与 incremental::state 的 file_fingerprints 同源:
            // state.rs 以 root.path().join(insight.path) 记录源文件指纹)——
            // 源文件在根下,产物在根/.code-repo-wiki 下,二者必须区分
            crate::incremental::state::GenerationState::compute_file_fingerprint(
                &root.path().join(f),
            )
            .unwrap_or_else(|_| "missing".to_string())
        })
        .collect::<Vec<_>>()
        .join("|")
}

/// 引用契约重试上限(P0-1):生成后校验源码引用,无效时重试注入
/// 反馈,最多重试 CITATION_RETRY_MAX 次(共 CITATION_RETRY_MAX + 1 次
/// 调用)。"总是重试"但必须有上限防死循环(每次重试消耗 LLM 调用)。
pub const CITATION_RETRY_MAX: usize = 2;

/// Mermaid 语法校验重试上限(G2):与引用契约对齐——首次调用 + 每次
/// 坏块反馈后重试,共 `MERMAID_RETRY_MAX + 1` 次调用;耗尽后降级
/// (坏块转 text + 标记注释,见 output::mermaid_check::degrade_mermaid_blocks)。
/// 与 CITATION_RETRY_MAX 合并进同一重试循环(上限取两者最大值)。
pub const MERMAID_RETRY_MAX: usize = crate::output::mermaid_check::MERMAID_RETRY_MAX;

/// 调用级失败重试上限(v22 修复):瞬时网络错误(连接重置/超时/5xx)
/// 重试最多 CALL_RETRY_MAX 次(含首次),间隔 500ms/1000ms 指数退避。
/// 所有错误统一按可重试处理:持久错误(参数/鉴权 4xx)最多多等 1.5s
/// 后仍原样返回给外层失败隔离——不吞错、不掩盖,与「重试语义只补偿
/// 瞬时故障」的边界一致;校验类失败不在此处处理(走既有反馈循环)。
const CALL_RETRY_MAX: usize = 3;

/// 带退避的 LLM 调用重试
///
/// provider 泛型化(Mock 也走此路径):mock/测试注入的失败因此最多
/// 慢 1.5s,换来真实 provider 的瞬时错误自愈——长流水线中每模块一次
/// 重试比整轮重跑便宜两个数量级。
async fn complete_with_retry<P: LlmProvider>(
    provider: &P,
    messages: &[Message],
    module: &str,
) -> Result<String> {
    let mut last_err = None;
    for attempt in 0..CALL_RETRY_MAX {
        match provider.complete(messages).await {
            Ok(content) => return Ok(content),
            Err(e) => {
                tracing::warn!(
                    "LLM 调用失败(第 {} 次重试): {} {}",
                    attempt + 1,
                    module,
                    e
                );
                last_err = Some(e);
                tokio::time::sleep(std::time::Duration::from_millis(500 << attempt)).await;
            }
        }
    }
    Err(last_err.expect("CALL_RETRY_MAX 至少为 1"))
}

impl<'a, P: LlmProvider> WikiGenerator<'a, P> {
    /// 使用指定的 LLM Provider 创建 WikiGenerator
    ///
    /// max_concurrent 控制 describe_modules 的并行上限(0 表示不限制)。
    pub fn new(provider: &'a P, max_concurrent: usize) -> Self {
        // tokio Semaphore 许可数有 MAX_PERMITS 上限(约 2^61),usize::MAX 会 panic;
        // "0=不限制" 用足够大的许可数表达(对真实并发规模永不构成瓶颈)
        let max = if max_concurrent == 0 { 1_000_000_000 } else { max_concurrent };
        Self {
            provider,
            call_count: AtomicUsize::new(0),
            failed: std::sync::Mutex::new(Vec::new()),
            semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(max)),
            // 缓存懒加载:首次 describe_modules 调用时按需读盘
            desc_cache: std::sync::Mutex::new(None),
            // HEAD 短哈希懒加载:首次页面构造时计算(非 git 仓库缓存 None)
            head_short: std::sync::OnceLock::new(),
        }
    }

    /// 返回已完成的 LLM 调用次数
    pub fn llm_call_count(&self) -> usize {
        self.call_count.load(Ordering::Relaxed)
    }

    /// 返回生成失败的模块名列表(演进计划 T3.2:失败隔离的可见性出口)
    pub fn failed_modules(&self) -> Vec<String> {
        self.failed.lock().map(|g| g.clone()).unwrap_or_default()
    }

    /// 记录一个模块的生成失败(内部使用,并发安全)
    pub(crate) fn record_failure(&self, module: String) {
        if let Ok(mut failed) = self.failed.lock() {
            failed.push(module);
        }
    }

    /// 生成模块 Wiki 页面
    ///
    /// * `chunk` — 模块的代码数据块
    /// * `card_summary` — 之前生成的 Knowledge Card 摘要,作为上下文参考
    /// * `config` — Wiki 配置(用于获取语言设置等)
    ///
    /// P0-1 引用契约:生成后校验源码引用(文件存在 + 行号有效),
    /// 引用无效时重试(最多 `CITATION_RETRY_MAX` 次,用户决策"总是重试")。
    /// 重试注入错误反馈(无效引用清单),最后一次仍失败时返回错误——
    /// 由调用方失败隔离(record_failure)记录,不产出无引用的页面。
    /// G2 Mermaid 契约:正文中的 Mermaid 代码块同样校验(merman 权威解析),
    /// 坏块错误消息注入重试反馈;重试耗尽后**降级**而非失败——坏块替换为
    /// text fence + 标记注释(OpenWiki degrade-and-repair),页面照常产出。
    pub async fn generate_wiki_page(
        &self,
        chunk: &Chunk,
        card_summary: &str,
        config: &WikiConfig,
        root: &crate::project::ProjectRoot,
        entity_ranges: Option<&crate::output::citation::EntityRanges>,
    ) -> Result<WikiDocument> {
        if chunk.is_empty() {
            anyhow::bail!("空块,跳过 Wiki 页面生成");
        }

        let language = &config.wiki.language;
        let mut messages =
            prompt::wiki_page_prompt(chunk, card_summary, language, &config.wiki.guide.notes);
        let mut content = String::new();
        let mut last_invalid = Vec::new();
        let mut last_mermaid = Vec::new();

        // 重试循环:首次调用 + 每次校验失败后追加反馈重试(共 RETRY_MAX + 1 次调用)。
        // 引用与 Mermaid 校验共享同一循环(上限取两者最大值——当前均为 2),
        // 每次调用后两类校验都执行,任一失败都注入对应反馈。
        let retry_max = CITATION_RETRY_MAX.max(MERMAID_RETRY_MAX);
        for attempt in 0..=retry_max {
            self.call_count.fetch_add(1, Ordering::Relaxed);
            // v22 修复:调用级失败(连接重置/超时/5xx 等瞬时错误)原先
            // 直接 `?` 抛出——长任务中瞬时错误会让整个模块页静默丢失
            // (Unity 实测 10 个模块页因调用失败而缺失,卡片页一并丢失)。
            // 此处只重试调用错误;校验失败走下方既有反馈循环。
            content = complete_with_retry(self.provider, &messages, &chunk.module_path.join("::"))
                .await?;
            // 空/纯空白内容同样视为校验失败(重试语义:不产出空白页面)
            last_invalid = if content.trim().is_empty() {
                vec![crate::output::citation::InvalidCitation {
                    citation: crate::output::citation::Citation {
                        path: String::new(),
                        start: 0,
                        end: 0,
                    },
                    reason: "输出为空(未生成任何内容)".into(),
                }]
            } else {
                // v14 B 组(t03 拍板):生产路径传实体行区间表 → 两级校验
                // (文件级 + 区间重叠);None(测试/无表场景)退化为文件级。
                match entity_ranges {
                    Some(ranges) => {
                        crate::output::citation::validate_citations_against_entities(
                            root.path(),
                            &content,
                            ranges,
                        )
                    }
                    None => crate::output::citation::validate_citations(root.path(), &content),
                }
            };
            last_mermaid = crate::output::mermaid_check::validate_mermaid_blocks(&content);
            if last_invalid.is_empty() && last_mermaid.is_empty() {
                break;
            }
            if !last_invalid.is_empty() {
                tracing::warn!(
                    "Wiki 页面引用校验失败(第 {} 次,无效 {} 条): {}",
                    attempt + 1,
                    last_invalid.len(),
                    chunk.module_path.join("::")
                );
            }
            if !last_mermaid.is_empty() {
                tracing::warn!(
                    "Wiki 页面 Mermaid 校验失败(第 {} 次,坏块 {} 个): {}",
                    attempt + 1,
                    last_mermaid.len(),
                    chunk.module_path.join("::")
                );
            }
            // 重试机会已用完:跳出循环走收尾路径
            if attempt == retry_max {
                break;
            }
            if !last_invalid.is_empty() {
                messages.push(Message::user(
                    crate::output::citation::retry_feedback(&last_invalid),
                ));
            }
            if !last_mermaid.is_empty() {
                messages.push(Message::user(
                    crate::output::mermaid_check::mermaid_retry_feedback(&last_mermaid),
                ));
            }
        }

        // 引用校验:重试耗尽仍无效 → 失败(不产出无引用的页面)
        if !last_invalid.is_empty() {
            anyhow::bail!(
                "Wiki 页面引用校验失败(重试 {} 次仍无效,共 {} 条无效引用): {}",
                retry_max,
                last_invalid.len(),
                chunk.module_path.join("::")
            );
        }
        // Mermaid 校验:重试耗尽仍坏 → 降级(坏块转 text + 注释),页面保留。
        // 降级注释含错误消息,供人工与下次 LLM 生成时参考修复(repair 语义)。
        if !last_mermaid.is_empty() {
            tracing::warn!(
                "Wiki 页面 Mermaid 重试耗尽({} 个坏块),降级为 text 块: {}",
                last_mermaid.len(),
                chunk.module_path.join("::")
            );
            content = crate::output::mermaid_check::degrade_mermaid_blocks(&content, &last_mermaid);
        }

        let now = chrono::Utc::now().to_rfc3339();
        // v32 10.2:页面基线行——HEAD 短哈希(非 git 仓库为 None 省略)。
        // 每轮生成只计算一次(OnceCell 缓存),模块页循环内不重复开仓库。
        let based_on_commit = self.head_short_for(root);

        Ok(WikiDocument {
            // 标题 = 完整模块路径("src::generate"):crossref 校验与概览/架构
            // 页引用的 target_title(模块名)一致,且链接文本比末段更可辨识;
            // 页面文件名由 module_path 派生,与标题解耦
            title: chunk.module_path.join("::"),
            kind: DocumentKind::WikiPage,
            content,
            language: config.wiki.language.clone(),
            module_path: chunk.module_path.clone(),
            references: build_references(chunk, &config.wiki.language),
            last_updated: now,
            based_on_commit,
            fingerprint: None,
        })
    }

    /// 带 Mermaid 校验的 LLM 调用(G2):架构/概览等全局文档的专用路径
    ///
    /// 这些页面由 LLM 自由生成,可能包含 Mermaid 图(架构图等)。调用后校验
    /// 正文 Mermaid 块,坏块错误消息注入重试反馈;重试耗尽后降级(坏块转
    /// text + 注释)而非失败——页面照常产出,坏图不出现在产物中。
    /// 与 generate_wiki_page 的区别:这里只校验 Mermaid(无引用契约——
    /// 全局文档不强制源码引用,与既有行为一致)。
    /// 实现委托自由函数 `complete_with_mermaid_guard_free`(U03/D1:
    /// Schema 文档复用同一校验-重试-降级路径),此处只负责调用计数。
    async fn complete_with_mermaid_guard(
        &self,
        messages: Vec<Message>,
        label: &str,
    ) -> Result<String> {
        complete_with_mermaid_guard_free(self.provider, messages, label, Some(&self.call_count))
            .await
    }

    /// 生成架构概览页面
    ///
    /// 基于所有模块的生成输出和知识图谱,生成项目级的架构概览文档。
    /// 生成前先为每个模块生成一行职责描述(describe_modules),
    /// 填充到模块快照的 description 字段——架构 prompt 依此输出模块职责,
    /// 取代原本恒为 None 的空描述(此前的 overview 只有模块名+节点数+边计数,
    /// 无法表达"模块负责什么")。
    pub async fn generate_architecture(
        &self,
        output: &GenerationOutput,
        graph: &KnowledgeGraph,
        config: &WikiConfig,
        root: &crate::project::ProjectRoot,
    ) -> Result<WikiDocument> {
        let language = &config.wiki.language;
        let modules = self.describe_modules(graph, language, config, root).await;
        let messages =
            prompt::architecture_overview_prompt(&modules, graph, language);
        // LLM 调用计数在 complete_with_mermaid_guard 内部(含重试)
        let content = self.complete_with_mermaid_guard(messages, "架构概览").await?;
        let now = chrono::Utc::now().to_rfc3339();
        // v32 10.2:架构页也带基线行(与模块页一致;OnceCell 复用同值)
        let based_on_commit = self.head_short_for(root);

        Ok(WikiDocument {
            title: "架构概览".into(),
            kind: DocumentKind::ArchitectureOverview,
            content,
            language: config.wiki.language.clone(),
            module_path: vec![],
            references: output
                .cards
                .iter()
                .map(|c| Reference {
                    target_title: c.module_name.clone(),
                    target_path: format!(
                        "wiki/{}/{}.md",
                        config.wiki.language,
                        // 模块页写盘文件名 = module_path.join("_")(见 output::wiki_file_name),
                        // 链接必须与之一致,否则 TOC/概览出现断链
                        c.module_name.replace("::", "_")
                    ),
                    relation: "module".into(),
                })
                .collect(),
            last_updated: now,
            based_on_commit,
            fingerprint: None,
        })
    }

    /// 为每个模块生成一行职责描述(LLM),返回带 description 的模块快照
    ///
    /// 逐模块一条 user 消息:输入 = 模块名 + 实体名列表 + 依赖模块列表,
    /// 输出 = 一句话职责(≤30 字)。跳过 src 兜底模块(它吸收未聚类文件,
    /// 无明确职责边界,描述会失真);LLM 失败时保留空描述(降级不影响主流程)。
    /// 各模块描述**并行**生成(join_all 保留顺序)——串行在 10+ 模块仓库会
    /// 拖长架构概览/项目概览的生成(真实 LLM 实测超时 10min 的根因之一)。
    ///
    /// v31 C-02 缓存:内存 memo(同一生成器实例内架构页+概览页两次调用
    /// 只对同一模块 LLM 一次)+ 落盘缓存(.state/module_descriptions.json,
    /// 键=模块名@语言,值=描述+模块文件内容指纹)——增量更新时未变模块
    /// 直接复用上次描述,不再重复消耗 LLM token。
    async fn describe_modules(
        &self,
        graph: &KnowledgeGraph,
        language: &str,
        config: &WikiConfig,
        root: &crate::project::ProjectRoot,
    ) -> Vec<crate::model::ModuleCluster> {
        // 缓存文件路径与 generation_state.json 同目录(.state/),
        // 随输出目录隔离(不同仓库/不同输出互不污染)
        let cache_path = config.output_dir().join(".state").join("module_descriptions.json");
        // 懒加载:首次调用读盘(损坏→空缓存,回退 LLM 重新生成不阻断)
        {
            let mut guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
            if guard.is_none() {
                *guard = Some(ModuleDescCache::load(&cache_path));
            }
        }
        // 并行生成所有需描述的模块描述(保留输入顺序);Semaphore 限制
        // 并发(演进计划 T5.1):0=不限时许可数巨大永不会阻塞。
        let semaphore = self.semaphore.clone();
        let futures: Vec<_> = graph
            .modules
            .iter()
            .map(|module| {
                let semaphore = semaphore.clone();
                let cache_key = format!("{}@{}", module.name, language);
                let fingerprint = module_files_fingerprint(module, graph, root);
                async move {
                    // 兜底模块(src)与空模块跳过:无职责边界可描述
                    if module.name == "src" || module.node_ids.is_empty() {
                        return module.clone();
                    }
                    // 缓存命中(指纹一致):直接复用,不触发 LLM 调用
                    {
                        let guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
                        if let Some(cache) = guard.as_ref()
                            && let Some(entry) = cache.entries.get(&cache_key)
                            && entry.fingerprint == fingerprint
                        {
                            let mut enriched = module.clone();
                            enriched.description = Some(entry.description.clone());
                            return enriched;
                        }
                    }
                    let _permit = match semaphore.acquire().await {
                        Ok(p) => p,
                        Err(_) => return module.clone(),
                    };
                    let mut enriched = module.clone();
                    if let Ok(text) = self.describe_module(module, graph, language).await
                        && !text.trim().is_empty()
                    {
                        let description = text.trim().to_string();
                        // 写回缓存(锁内短操作,不跨 await)——失败不缓存,
                        // 下次调用重新尝试 LLM
                        {
                            let mut guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
                            if let Some(cache) = guard.as_mut() {
                                cache.entries.insert(
                                    cache_key,
                                    CacheEntry {
                                        fingerprint,
                                        description: description.clone(),
                                    },
                                );
                            }
                        }
                        enriched.description = Some(description);
                    }
                    enriched
                }
            })
            .collect();
        let modules = futures::future::join_all(futures).await;
        // 整批完成后原子落盘(跨进程复用);写盘失败仅告警不阻断
        {
            let guard = self.desc_cache.lock().unwrap_or_else(|e| e.into_inner());
            if let Some(cache) = guard.as_ref() {
                cache.save(&cache_path);
            }
        }
        modules
    }

    /// 生成单个模块的一句话职责描述(LLM)
    async fn describe_module(
        &self,
        module: &crate::model::ModuleCluster,
        graph: &KnowledgeGraph,
        language: &str,
    ) -> Result<String> {
        self.call_count.fetch_add(1, Ordering::Relaxed);

        // 模块职责描述输入:实体名收集(t02 拍板:行为型优先排序 + 排除字段级)
        let entity_names = collect_module_entity_names(module, graph);

        let messages = prompt::module_description_prompt(
            &module.name,
            &entity_names,
            language,
        );
        self.provider.complete(&messages).await
    }

    /// 生成项目概览页面
    ///
    /// 与 generate_architecture 同签名同风格:基于完整 KnowledgeGraph 的
    /// 模块列表与模块间依赖摘要,生成全仓库概览(技术栈/目录结构/核心模块)。
    pub async fn generate_overview(
        &self,
        output: &GenerationOutput,
        graph: &KnowledgeGraph,
        config: &WikiConfig,
        root: &crate::project::ProjectRoot,
    ) -> Result<WikiDocument> {
        // 与 generate_architecture 一致:先补模块职责描述,概览内容才能
        // 表达"模块负责什么";再叠加卡片摘要(自底向上合成:父概览基于
        // 子模块的职责描述 + 卡片摘要生成,而非仅模块名/节点数/边计数)
        // LLM 调用计数在 complete_with_mermaid_guard 内部(含重试)
        let modules = self.describe_modules(graph, &config.wiki.language, config, root).await;
        let messages = vec![Message::user(overview_prompt(&modules, &output.cards, graph, config))];
        let content = self.complete_with_mermaid_guard(messages, "项目概览").await?;
        let now = chrono::Utc::now().to_rfc3339();
        // v32 10.2:概览页也带基线行(与模块页一致;OnceCell 复用同值)
        let based_on_commit = self.head_short_for(root);

        Ok(WikiDocument {
            title: "项目概览".into(),
            kind: DocumentKind::ProjectOverview,
            content,
            language: config.wiki.language.clone(),
            module_path: vec![],
            references: output
                .cards
                .iter()
                .map(|c| Reference {
                    target_title: c.module_name.clone(),
                    target_path: format!(
                        "wiki/{}/{}.md",
                        config.wiki.language,
                        // 模块页写盘文件名 = module_path.join("_")(见 output::wiki_file_name),
                        // 链接必须与之一致,否则 TOC/概览出现断链
                        c.module_name.replace("::", "_")
                    ),
                    relation: "module".into(),
                })
                .collect(),
            last_updated: now,
            based_on_commit,
            fingerprint: None,
        })
    }
}

/// 模块职责描述输入:收集模块内实体名(t02 拍板:行为型优先排序 + 排除字段级)
///
/// 实体名额有限(DESCRIBE_ENTITY_CAP = 30),图节点顺序(解析顺序)不代表
/// 职责信号强弱:函数/方法(Calls 边承载者)是模块职责的主要信号,结构体/
/// 枚举/常量次之,字段(variable)对职责判断价值最低且数量大(t01 调研:
/// 字段级实体占比约 1.29 倍)——不排序时字段会挤占函数名额,且字段是
/// lint stale 假漂移的噪声来源(v20 审计:C# 私有字段曾被误报为页面声称
/// 但源码不存在的实体)。排序规则:kind 优先级(函数族 > 数据族 > 常量)
/// + 名称字典序(确定性输出,跨次生成一致),容器节点与 Variable 不进名额。
pub(crate) const DESCRIBE_ENTITY_CAP: usize = 30;

/// kind → 职责信号优先级(0 最高,仅排序用,不改变实体本身语义)
fn entity_priority(kind: &crate::model::NodeKind) -> u8 {
    match kind {
        // 行为型:函数/方法/宏/接口/抽象——模块"做什么"的直接信号
        crate::model::NodeKind::Function
        | crate::model::NodeKind::Trait
        | crate::model::NodeKind::Impl
        | crate::model::NodeKind::Interface
        | crate::model::NodeKind::Class
        | crate::model::NodeKind::Macro => 0,
        // 数据型:结构体/枚举/类型别名
        crate::model::NodeKind::Struct
        | crate::model::NodeKind::Enum
        | crate::model::NodeKind::Type => 1,
        // 常量
        crate::model::NodeKind::Constant => 2,
        // 容器与字段在上层过滤,不应到达此处
        _ => 3,
    }
}

/// 收集模块内实体名(排序 + 截断名额)
pub(crate) fn collect_module_entity_names(
    module: &crate::model::ModuleCluster,
    graph: &KnowledgeGraph,
) -> Vec<String> {
    let mut names: Vec<(u8, String)> = module
        .node_ids
        .iter()
        .filter_map(|nid| graph.graph.node_weight(*nid))
        .filter(|n| {
            !matches!(
                n.kind,
                crate::model::NodeKind::Project
                    | crate::model::NodeKind::Module
                    | crate::model::NodeKind::File
                    | crate::model::NodeKind::Variable
            )
        })
        .map(|n| (entity_priority(&n.kind), n.name.clone()))
        .collect();
    // 稳定排序:优先级升序 + 名称字典序(确定性输出)
    names.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
    names
        .into_iter()
        .map(|(_, name)| name)
        .take(DESCRIBE_ENTITY_CAP)
        .collect()
}

/// 带 Mermaid 校验的 LLM 调用(自由函数版,U03/D1 提取)
///
/// 与 WikiGenerator::complete_with_mermaid_guard 等价,但直接接受
/// provider 与可选调用计数指针——Schema 文档生成(generate/schema.rs)
/// 只有 Provider 没有 WikiGenerator,此前直接 complete 绕过校验(唯一
/// 强制 erDiagram 输出的文档类型反而无校验-重试-降级,D1)。
pub async fn complete_with_mermaid_guard_free<P: LlmProvider>(
    provider: &P,
    mut messages: Vec<Message>,
    label: &str,
    call_count: Option<&std::sync::atomic::AtomicUsize>,
) -> Result<String> {
    use std::sync::atomic::Ordering;
    let mut content = String::new();
    let mut last_mermaid = Vec::new();

    for attempt in 0..=MERMAID_RETRY_MAX {
        if let Some(c) = call_count {
            c.fetch_add(1, Ordering::Relaxed);
        }
        content = provider.complete(&messages).await?;
        last_mermaid = crate::output::mermaid_check::validate_mermaid_blocks(&content);
        if last_mermaid.is_empty() {
            return Ok(content);
        }
        tracing::warn!(
            "{label} Mermaid 校验失败(第 {} 次,坏块 {} 个)",
            attempt + 1,
            last_mermaid.len()
        );
        if attempt == MERMAID_RETRY_MAX {
            break;
        }
        messages.push(Message::user(
            crate::output::mermaid_check::mermaid_retry_feedback(&last_mermaid),
        ));
    }

    tracing::warn!("{label} Mermaid 重试耗尽({} 个坏块),降级为 text 块", last_mermaid.len());
    Ok(crate::output::mermaid_check::degrade_mermaid_blocks(
        &content,
        &last_mermaid,
    ))
}

/// 生成项目概览的 prompt(单条 user 消息,模板风格与 architecture_overview_prompt 一致)
///
/// 输入 = 模块列表(含职责描述)+ 卡片摘要(自底向上合成的一层:概览基于
/// 子模块的卡片摘要生成)+ 模块间依赖摘要,输出 = 技术栈 / 目录结构 / 核心模块。
fn overview_prompt(
    modules: &[crate::model::ModuleCluster],
    cards: &[crate::model::KnowledgeCard],
    graph: &KnowledgeGraph,
    config: &WikiConfig,
) -> String {
    let mut parts = Vec::new();

    parts.push(format!(
        "你是一个资深软件架构师,负责为整个项目生成人类可读的项目概览文档。\n\n\
         请基于下面的模块聚类信息、各模块卡片摘要和模块间依赖摘要,输出以下结构:\n\n\
         # 项目概览\n\n\
         ## 技术栈\n根据模块名称与依赖关系推断项目使用的技术栈。\n\n\
         ## 目录结构\n根据模块划分描述仓库的目录结构。\n\n\
         ## 核心模块\n列出核心模块及其职责。\n\n\
         请用 {} 语言输出。保留 Markdown 格式。",
        config.wiki.language
    ));

    parts.push("## 模块列表".to_string());
    for module in modules {
        let desc = module.description.as_deref().unwrap_or("");
        parts.push(format!(
            "- {} (节点数: {}{})",
            module.name,
            module.node_ids.len(),
            if desc.is_empty() {
                String::new()
            } else {
                format!(", 职责: {}", desc)
            }
        ));
    }

    // 卡片摘要:模块级详情的浓缩(自底向上合成的输入)。
    // 每卡片一行:模块名 + 摘要 + 关键实体(实体名列表,数量多于描述输入)
    if !cards.is_empty() {
        parts.push("## 模块卡片摘要".to_string());
        for card in cards {
            let entities: Vec<&str> = card
                .key_entities
                .iter()
                .map(|e| e.name.as_str())
                .take(8)
                .collect();
            parts.push(format!(
                "- {}: {}(关键实体: {}",
                card.module_name,
                card.summary,
                if entities.is_empty() {
                    "".to_string()
                } else {
                    entities.join(", ")
                }
            ));
        }
    }

    // 模块间依赖摘要:建立 节点→模块 映射后,按模块对聚合非 Contains 边
    let mut module_of: std::collections::HashMap<NodeId, &str> = Default::default();
    for module in modules {
        for nid in &module.node_ids {
            module_of.insert(*nid, module.name.as_str());
        }
    }
    let mut deps: std::collections::BTreeMap<(String, String), usize> = Default::default();
    for edge in graph.graph.edge_weights() {
        if edge.kind == EdgeKind::Contains {
            continue;
        }
        let (Some(src), Some(dst)) = (module_of.get(&edge.source), module_of.get(&edge.target))
        else {
            continue;
        };
        *deps.entry((src.to_string(), dst.to_string())).or_default() += 1;
    }
    if deps.is_empty() {
        parts.push("\n## 模块间依赖\n(图中未检测到模块间依赖边)".to_string());
    } else {
        parts.push("\n## 模块间依赖".to_string());
        for ((src, dst), count) in deps {
            parts.push(format!("- {}{} ({} 条边)", src, dst, count));
        }
    }

    parts.join("\n")
}

/// 从 Chunk 构建交叉引用
///
/// * `language` — 目标语言目录名,链接指向 `wiki/{language}/` 下的页面
fn build_references(chunk: &Chunk, language: &str) -> Vec<Reference> {
    chunk
        .dependencies
        .iter()
        .map(|dep| Reference {
            target_title: dep.clone(),
            target_path: format!(
                "wiki/{language}/{}.md",
                // 依赖模块页文件名与输出层 wiki_file_name 一致("::" → "_")
                dep.replace("::", "_")
            ),
            relation: "depends_on".into(),
        })
        .collect()
}

/// 确定性架构/概览骨架(U06/D12):provider 失败时降级而非丢页
///
/// 内容 = 模块列表(名 + 实体数 + 依赖清单),全部来自 knowledge graph
/// (零 LLM 调用),与 index.md 的确定性骨架同一思路:页面存在性优先,
/// LLM 摘要可在下次成功生成时补齐。kind/title 由调用方传入(架构概览与
/// 项目概览共用,避免两份骨架代码漂移)。
pub fn fallback_architecture_doc(
    graph: &KnowledgeGraph,
    config: &WikiConfig,
    kind: DocumentKind,
    title: &str,
) -> WikiDocument {
    use petgraph::visit::{EdgeRef, IntoEdgeReferences};
    use std::collections::{BTreeMap, BTreeSet, HashMap};

    // 实体节点 → 所属模块(先到先得,与 index.rs/export_modules 同规则)
    let mut node_module: HashMap<NodeId, String> = HashMap::new();
    for module in &graph.modules {
        for nid in &module.node_ids {
            node_module
                .entry(*nid)
                .or_insert_with(|| module.name.clone());
        }
    }
    // 模块 → 依赖模块名集合(Calls + Imports 跨模块边,BTreeSet 字典序)
    let mut deps: BTreeMap<String, BTreeSet<String>> = Default::default();
    for edge in graph.graph.edge_references() {
        if matches!(
            graph.graph[edge.id()].kind,
            EdgeKind::Calls | EdgeKind::Imports
        ) {
            let (Some(src), Some(tgt)) = (
                node_module.get(&edge.source()),
                node_module.get(&edge.target()),
            ) else {
                continue;
            };
            if src != tgt {
                deps.entry(src.clone()).or_default().insert(tgt.clone());
            }
        }
    }

    let mut body = format!(
        "# {title}\n\n> LLM 生成不可用,本页为确定性骨架:模块与依赖关系由知识图谱自动生成(无 LLM 摘要)。\n\n## 模块\n\n"
    );
    for module in &graph.modules {
        body.push_str(&format!("- `{}`({} 个实体)", module.name, module.node_ids.len()));
        if let Some(dl) = deps.get(&module.name)
            && !dl.is_empty()
        {
            body.push_str(&format!(" — 依赖 {}", dl.iter().cloned().collect::<Vec<_>>().join(", ")));
        }
        body.push('\n');
    }

    let mut refs: Vec<Reference> = graph
        .modules
        .iter()
        .map(|m| Reference {
            target_title: m.name.clone(),
            target_path: format!(
                "wiki/{}/{}.md",
                config.wiki.language,
                m.name.replace("::", "_")
            ),
            relation: "module".into(),
        })
        .collect();
    // 按目标标题字典序,保证输出确定性
    refs.sort_by(|a, b| a.target_title.cmp(&b.target_title));

    WikiDocument {
        title: title.to_string(),
        kind,
        content: body,
        language: config.wiki.language.clone(),
        module_path: vec![],
        references: refs,
        last_updated: chrono::Utc::now().to_rfc3339(),
        // 测试辅助构造:基于提交行由调用方显式指定(默认无)
        based_on_commit: None,
        fingerprint: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::generate::chunk::chunk_by_file;
    use crate::generate::llm::MockProvider;
    use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
    
    use std::path::PathBuf;

    /// 可编程 mock:按调用次数依次返回预设响应(引用重试测试用)
    struct ScriptedProvider {
        responses: std::sync::Mutex<std::vec::IntoIter<String>>,
        calls: std::sync::atomic::AtomicUsize,
    }

    impl ScriptedProvider {
        fn new(responses: Vec<String>) -> Self {
            Self {
                responses: std::sync::Mutex::new(responses.into_iter()),
                calls: std::sync::atomic::AtomicUsize::new(0),
            }
        }
    }

    impl LlmProvider for ScriptedProvider {
        async fn complete(&self, _messages: &[Message]) -> Result<String> {
            self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            self.responses
                .lock()
                .unwrap()
                .next()
                .ok_or_else(|| anyhow::anyhow!("预设响应耗尽"))
        }
    }

    /// 前 n 次调用失败、之后成功的 provider(调用级重试测试用)
    struct FlakyProvider {
        fail_times: std::sync::atomic::AtomicUsize,
        calls: std::sync::atomic::AtomicUsize,
    }

    impl FlakyProvider {
        fn new(fail_times: usize) -> Self {
            Self {
                fail_times: std::sync::atomic::AtomicUsize::new(fail_times),
                calls: std::sync::atomic::AtomicUsize::new(0),
            }
        }
    }

    impl LlmProvider for FlakyProvider {
        async fn complete(&self, _messages: &[Message]) -> Result<String> {
            self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            // 剩余失败次数 > 0 时失败,减到 0 后成功
            let remaining = self.fail_times.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
            if remaining > 0 {
                Err(anyhow::anyhow!("模拟瞬时网络错误"))
            } else {
                Ok("重试成功".to_string())
            }
        }
    }

    #[tokio::test]
    async fn test_complete_with_retry_recovers_after_transient_failure() {
        // 首次失败、第二次成功:重试自愈,返回内容正确
        let provider = FlakyProvider::new(1);
        let content = complete_with_retry(&provider, &[], "src::test").await.unwrap();
        assert_eq!(content, "重试成功");
        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2);
    }

    #[tokio::test]
    async fn test_complete_with_retry_gives_up_after_max_attempts() {
        // 持续失败:重试耗尽后原样返回错误(不吞错、不掩盖)
        let provider = FlakyProvider::new(10);
        let err = complete_with_retry(&provider, &[], "src::test")
            .await
            .unwrap_err();
        assert!(err.to_string().contains("模拟瞬时网络错误"));
        assert_eq!(
            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
            CALL_RETRY_MAX
        );
    }

    fn make_test_chunk() -> Chunk {
        let entity = Entity {
            name: "Server".into(),
            kind: "struct".into(),
            line_start: 1,
            line_end: 50,
            doc_comment: Some("HTTP 服务".into()),
            signature: None, visibility: None,
        };
        let insight = FileInsight {
            path: PathBuf::from("src/server.rs"),
            language: "rust".into(),
            entities: vec![entity],
            imports: vec![ImportStmt {
                source: "tokio".into(),
                alias: None,
                line: 1,
            }],
            doc_comments: vec![],
            source: String::new(),
        };
        chunk_by_file(&insight)
    }

    #[tokio::test]
    async fn test_skip_empty_chunk() {
        let provider = MockProvider::new();
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let root = crate::project::ProjectRoot::new(std::env::temp_dir());
        let empty_chunk = Chunk {
            module_path: vec![],
            entities: vec![],
            imports: vec![],
            dependencies: vec![],
            file_paths: vec![],
            entity_sources: vec![],
        };

        let result = generator.generate_wiki_page(&empty_chunk, "", &config, &root, None).await;
        assert!(result.is_err());
    }

    /// 引用契约重试:无效引用 → 重试注入反馈 → 第二次输出有效引用则成功
    #[tokio::test]
    async fn test_wiki_page_retries_on_invalid_citation() {
        // 临时目录放一个真实文件 src/server.rs(3 行),使有效引用通过校验
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_retry_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let src = dir.join("src");
        std::fs::create_dir_all(&src).unwrap();
        std::fs::write(src.join("server.rs"), "pub struct Server;\n// comment\n").unwrap();
        let root = crate::project::ProjectRoot::new(dir.clone());

        // 第一次输出引用不存在的文件,第二次输出有效引用
        let provider = ScriptedProvider::new(vec![
            "模块职责是管理连接。核心实体 `Server` 定义见 nonexistent.rs:99。".to_string(),
            "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:1。".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
        assert!(doc.content.contains("src/server.rs:1"), "重试后应使用有效引用");
        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2, "应调用 2 次(1 次失败 + 1 次重试)");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 引用契约重试耗尽:超过 CITATION_RETRY_MAX 仍无效 → 报错
    #[tokio::test]
    async fn test_wiki_page_bails_when_citations_never_valid() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_fail_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let root = crate::project::ProjectRoot::new(dir.clone());

        // 全部输出无效引用(文件不存在)
        let provider = ScriptedProvider::new(vec![
            "引用 nonexistent.rs:99".to_string(),
            "引用 nonexistent.rs:99".to_string(),
            "引用 nonexistent.rs:99".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let result = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await;
        assert!(result.is_err(), "重试耗尽后应报错");
        let err = result.unwrap_err().to_string();
        assert!(err.contains("引用校验失败"), "错误信息应说明引用校验失败: {err}");
        assert_eq!(
            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
            CITATION_RETRY_MAX + 1,
            "应调用 CITATION_RETRY_MAX+1 次后放弃"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 引用契约放行:无引用的输出直接通过(契约只惩罚编造引用,不强制必须有)
    #[tokio::test]
    async fn test_wiki_page_without_citations_passes() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_ok_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let root = crate::project::ProjectRoot::new(dir.clone());

        let provider = ScriptedProvider::new(vec!["模块职责是管理连接。".to_string()]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
        assert_eq!(doc.content, "模块职责是管理连接。");
        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 1, "无引用无需重试");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// G2 Mermaid 契约重试:坏图 → 重试注入错误反馈 → 第二次输出好图则成功
    #[tokio::test]
    async fn test_wiki_page_retries_on_bad_mermaid() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_mermaid_retry_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let root = crate::project::ProjectRoot::new(dir.clone());

        // 第一次输出坏图(标签未闭合),第二次输出好图
        let provider = ScriptedProvider::new(vec![
            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
            "```mermaid\nflowchart LR\nA[Start] --> B[End]\n```\n".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
        assert!(doc.content.contains("A[Start] --> B[End]"), "重试后应保留好图");
        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 2, "应调用 2 次(1 次坏图 + 1 次重试)");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v14 B 组:区间重叠校验——文件存在且行号有效但引用区间不覆盖任何实体
    /// (行号对但内容错)→ 校验失败 → 重试反馈注入 → 修正后成功
    #[tokio::test]
    async fn test_wiki_page_retries_on_overlap_citation() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_overlap_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let src = dir.join("src");
        std::fs::create_dir_all(&src).unwrap();
        // 10 行文件:实体区间 (2,4)(fn Server 定义)
        let content: String = (1..=10).map(|i| format!("line{i}\n")).collect();
        std::fs::write(src.join("server.rs"), content).unwrap();
        let root = crate::project::ProjectRoot::new(dir.clone());

        let mut ranges: crate::output::citation::EntityRanges =
            crate::output::citation::EntityRanges::new();
        ranges.insert("src/server.rs".to_string(), vec![(2, 4)]);

        // 第一次输出引用 8 行(文件内但不在实体区间),第二次输出 2 行(覆盖实体)
        let provider = ScriptedProvider::new(vec![
            "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:8。".to_string(),
            "模块职责是管理连接。核心实体 `Server` 定义见 src/server.rs:2。".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let doc = generator
            .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
            .await
            .unwrap();
        assert!(doc.content.contains("src/server.rs:2"), "重试后应使用覆盖实体的引用");
        assert_eq!(
            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
            2,
            "区间外引用应触发一次重试"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v14 B 组:区间重叠校验重试耗尽 → 页面失败(t03 拍板:维持 bail,
    /// 与文件级引用校验同一失败语义,Mermaid 才是唯一降级路径)
    #[tokio::test]
    async fn test_wiki_page_bails_when_overlap_never_valid() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_overlap_fail_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let src = dir.join("src");
        std::fs::create_dir_all(&src).unwrap();
        let content: String = (1..=10).map(|i| format!("line{i}\n")).collect();
        std::fs::write(src.join("server.rs"), content).unwrap();
        let root = crate::project::ProjectRoot::new(dir.clone());

        let mut ranges: crate::output::citation::EntityRanges =
            crate::output::citation::EntityRanges::new();
        ranges.insert("src/server.rs".to_string(), vec![(2, 4)]);

        // 全部输出区间外引用(8 行)
        let provider = ScriptedProvider::new(vec![
            "核心实体 `Server` 见 src/server.rs:8。".to_string(),
            "核心实体 `Server` 见 src/server.rs:8。".to_string(),
            "核心实体 `Server` 见 src/server.rs:8。".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let result = generator
            .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
            .await;
        assert!(result.is_err(), "区间重叠校验重试耗尽应报错");
        let err = result.unwrap_err().to_string();
        assert!(err.contains("引用校验失败"), "错误信息应说明引用校验失败: {err}");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v14 B 组:无实体文件(README.md 等非代码文件)的引用放行——区间
    /// 校验只对有实体的文件生效(引用配置/说明文件是合法行为)
    #[tokio::test]
    async fn test_wiki_page_passes_non_code_file_citation() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_cite_noncode_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("README.md"), "docs\n").unwrap();
        let root = crate::project::ProjectRoot::new(dir.clone());

        // 实体表不含 README.md(无实体)
        let ranges: crate::output::citation::EntityRanges =
            crate::output::citation::EntityRanges::new();
        let provider = ScriptedProvider::new(vec![
            "模块说明见 README.md:1。".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let doc = generator
            .generate_wiki_page(&chunk, "摘要", &config, &root, Some(&ranges))
            .await
            .unwrap();
        assert!(doc.content.contains("README.md:1"), "无实体文件引用应放行");
        assert_eq!(provider.calls.load(std::sync::atomic::Ordering::Relaxed), 1, "无需重试");

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// G2 Mermaid 契约重试耗尽:超过 MERMAID_RETRY_MAX 仍坏图 → 降级而非失败
    /// (坏块替换为 text fence + 标记注释,页面照常产出)
    #[tokio::test]
    async fn test_wiki_page_degrades_when_mermaid_never_valid() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_mermaid_degrade_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let root = crate::project::ProjectRoot::new(dir.clone());

        // 连续 3 次(MERMAID_RETRY_MAX + 1)都输出坏图
        let provider = ScriptedProvider::new(vec![
            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
            "```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let chunk = make_test_chunk();

        let doc = generator.generate_wiki_page(&chunk, "摘要", &config, &root, None).await.unwrap();
        assert!(!doc.content.contains("```mermaid"), "坏图不应再以 mermaid 块出现");
        assert!(doc.content.contains("```text"), "坏块应降级为 text fence");
        assert!(doc.content.contains("code-repo-wiki: mermaid parse failed"), "应含降级标记注释");
        assert_eq!(
            provider.calls.load(std::sync::atomic::Ordering::Relaxed),
            MERMAID_RETRY_MAX + 1,
            "应调用 MERMAID_RETRY_MAX+1 次后降级"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }

    /// G2 架构概览:坏图重试耗尽同样降级,页面正常产出
    #[tokio::test]
    async fn test_architecture_degrades_on_bad_mermaid() {
        let provider = ScriptedProvider::new(vec![
            "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
            "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
            "```mermaid\nflowchart LR\nA[hello world\n```\n".to_string(),
        ]);
        let generator = WikiGenerator::new(&provider, 0);
        let config = WikiConfig::default();
        let graph = crate::model::KnowledgeGraph::default();
        let output = crate::generate::GenerationOutput {
            cards: vec![],
            documents: vec![],
            generation_stats: crate::generate::GenerationStats::default(),
            timings: crate::GenerationTimings::default(),
        };
        // 临时根目录:避免默认输出目录污染工作区(缓存/产物落临时目录)
        let root = crate::project::ProjectRoot::new(
            std::env::temp_dir().join(format!("rw_arch_mermaid_{}", std::process::id())),
        );

        let doc = generator
            .generate_architecture(&output, &graph, &config, &root)
            .await
            .unwrap();
        assert!(!doc.content.contains("```mermaid"), "坏图不应再以 mermaid 块出现");
        assert!(doc.content.contains("code-repo-wiki: mermaid parse failed"), "应含降级标记注释");
        let _ = std::fs::remove_dir_all(root.path());
    }

    #[test]
    fn test_build_references() {
        let chunk = Chunk {
            module_path: vec!["crate".into(), "net".into()],
            entities: vec![],
            imports: vec![],
            dependencies: vec!["tokio".into(), "serde".into()],
            file_paths: vec![],
            entity_sources: vec![],
        };

        let refs = build_references(&chunk, "zh");
        assert_eq!(refs.len(), 2);
        assert_eq!(refs[0].target_title, "tokio");
        assert_eq!(refs[0].target_path, "wiki/zh/tokio.md");
    }

    /// 链接路径与写盘文件名规则必须一致("::" → "_",与 output::wiki_file_name 相同),
    /// 否则 TOC/概览/模块互链全部断链(历史 bug:曾用 "::" → "/" 生成 wiki/zh/src/analysis.md
    /// 而实际写盘 src_analysis.md)
    #[test]
    fn test_build_references_uses_underscore_like_write_path() {
        let chunk = Chunk {
            module_path: vec!["src".into(), "generate".into()],
            entities: vec![],
            imports: vec![],
            dependencies: vec!["src::analysis".into(), "src::output".into()],
            file_paths: vec![],
            entity_sources: vec![],
        };

        let refs = build_references(&chunk, "zh");
        assert_eq!(refs[0].target_path, "wiki/zh/src_analysis.md");
        assert_eq!(refs[1].target_path, "wiki/zh/src_output.md");
    }

    /// 模块职责描述 prompt:含模块名与实体列表,并约束一行输出
    #[test]
    fn test_module_description_prompt_shape() {
        let messages = prompt::module_description_prompt(
            "src::net",
            &["connect".into(), "listen".into()],
            "zh",
        );
        let user = &messages[1].content;
        assert!(user.contains("src::net"), "应含模块名");
        assert!(user.contains("connect"), "应含实体名");
        assert!(user.contains("listen"), "应含实体名");
        assert!(messages[0].content.contains("30"), "zh 应约束 30 字内");
    }

    /// describe_modules:src 兜底模块跳过,带实体模块获得描述快照
    #[tokio::test]
    async fn test_describe_modules_enriches_description() {
        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
        use petgraph::stable_graph::StableDiGraph;

        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
        let f = g.add_node(CodeNode {
            id: NodeId::new(0),
            kind: NodeKind::File,
            name: "net.rs".into(),
            file_path: Some("src/net.rs".into()),
            line_range: None,
            doc_comment: None,
            signature: None, visibility: None,
            module_path: vec!["src".into(), "net".into()],
        });
        let e = g.add_node(CodeNode {
            id: NodeId::new(1),
            kind: NodeKind::Function,
            name: "connect".into(),
            file_path: Some("src/net.rs".into()),
            line_range: None,
            doc_comment: None,
            signature: None, visibility: None,
            module_path: vec!["src".into(), "net".into()],
        });
        let kg = KnowledgeGraph {
            graph: g,
            modules: vec![
                ModuleCluster {
                    name: "src::net".into(),
                    node_ids: vec![f, e],
                    cohesion: 0.5,
                    coupling: 0.5,
                    description: None,
                },
                ModuleCluster {
                    name: "src".into(),
                    node_ids: vec![],
                    cohesion: 1.0,
                    coupling: 0.0,
                    description: None,
                },
            ],
            features: Vec::new(),
        };
        let provider = MockProvider::new();
        let generator = WikiGenerator::new(&provider, 0);
        // 输出目录指向临时目录,避免缓存文件污染工作区
        let config = crate::config::schema::WikiConfig {
            output_dir: Some(
                std::env::temp_dir()
                    .join(format!("rw_desc_cache_test_{}", std::process::id())),
            ),
            ..Default::default()
        };
        let root = crate::project::ProjectRoot::new(
            std::env::temp_dir().join(format!("rw_desc_root_test_{}", std::process::id())),
        );
        let enriched = generator.describe_modules(&kg, "zh", &config, &root).await;
        assert_eq!(enriched.len(), 2);
        assert!(enriched[0].description.is_some(), "带实体的模块应获得描述");
        assert_eq!(enriched[1].name, "src");
        assert!(enriched[1].description.is_none(), "src 兜底模块不描述");
    }

    /// v31 C-02:同一生成器实例内第二次 describe_modules 命中缓存,
    /// 不再触发 LLM 调用(内存 memo)
    #[tokio::test]
    async fn test_describe_modules_cache_hit_skips_llm() {
        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
        use petgraph::stable_graph::StableDiGraph;

        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
        g.add_node(CodeNode {
            id: NodeId::new(0),
            kind: NodeKind::File,
            name: "net.rs".into(),
            file_path: Some("src/net.rs".into()),
            line_range: None,
            doc_comment: None,
            signature: None,
            visibility: None,
            module_path: vec!["src".into(), "net".into()],
        });
        let kg = KnowledgeGraph {
            graph: g,
            modules: vec![
                ModuleCluster {
                    name: "src::net".into(),
                    node_ids: vec![NodeId::new(0)],
                    cohesion: 1.0,
                    coupling: 0.0,
                    description: None,
                },
            ],
            features: Vec::new(),
        };
        let provider = MockProvider::new();
        let generator = WikiGenerator::new(&provider, 0);
        // 测试泄漏根治(v33 审计发现):目录按 PID 命名且从不清理——Windows
        // PID 复用时上一进程的落盘缓存被本进程读到,缓存命中断言变成
        // 「残留命中」而偶发失败。开头清理保证从干净基线开始。
        let out_dir = std::env::temp_dir().join(format!("rw_desc_cache_hit_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&out_dir);
        let root_dir = std::env::temp_dir().join(format!("rw_desc_root_hit_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root_dir);
        let config = crate::config::schema::WikiConfig {
            output_dir: Some(out_dir),
            ..Default::default()
        };
        let root = crate::project::ProjectRoot::new(root_dir);
        let first = generator.describe_modules(&kg, "zh", &config, &root).await;
        assert!(first[0].description.is_some(), "首次应走 LLM 获得描述");
        let calls_after_first = generator.llm_call_count();
        assert!(calls_after_first > 0, "首次必须真实调用 LLM");
        let second = generator.describe_modules(&kg, "zh", &config, &root).await;
        assert_eq!(second[0].description, first[0].description, "缓存应返回相同描述");
        assert_eq!(
            generator.llm_call_count(),
            calls_after_first,
            "第二次调用必须命中缓存、不触发 LLM"
        );
    }

    /// v31 C-02:模块源文件内容变化后指纹失效,缓存不命中、重新调用 LLM
    #[tokio::test]
    async fn test_describe_modules_cache_invalidated_by_file_change() {
        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
        use petgraph::stable_graph::StableDiGraph;

        // 真实临时源文件(describe 指纹按文件内容计算)
        let dir = std::env::temp_dir().join(format!("rw_desc_cache_chg_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("src")).unwrap();
        std::fs::write(dir.join("src/net.rs"), "pub fn connect() {}\n").unwrap();

        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
        g.add_node(CodeNode {
            id: NodeId::new(0),
            kind: NodeKind::File,
            name: "net.rs".into(),
            file_path: Some("src/net.rs".into()),
            line_range: None,
            doc_comment: None,
            signature: None,
            visibility: None,
            module_path: vec!["src".into(), "net".into()],
        });
        let kg = KnowledgeGraph {
            graph: g,
            modules: vec![ModuleCluster {
                name: "src::net".into(),
                node_ids: vec![NodeId::new(0)],
                cohesion: 1.0,
                coupling: 0.0,
                description: None,
            }],
            features: Vec::new(),
        };
        let provider = MockProvider::new();
        let generator = WikiGenerator::new(&provider, 0);
        // 真实布局:root=dir(源文件在 root/src/ 下),产物在 root/.code-repo-wiki 下
        let config = crate::config::schema::WikiConfig {
            output_dir: Some(dir.join(".code-repo-wiki")),
            ..Default::default()
        };
        let root = crate::project::ProjectRoot::new(dir.clone());
        generator.describe_modules(&kg, "zh", &config, &root).await;
        let calls_after_first = generator.llm_call_count();

        // 修改源文件内容 → 指纹变化 → 缓存失效
        std::fs::write(dir.join("src/net.rs"), "pub fn connect() {}\npub fn listen() {}\n").unwrap();
        generator.describe_modules(&kg, "zh", &config, &root).await;
        assert!(
            generator.llm_call_count() > calls_after_first,
            "文件内容变化后必须重新调用 LLM"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v31 C-02:缓存文件损坏(非法 JSON)时回退空缓存,不 panic、
    /// 重新调用 LLM、正常产出(缓存故障绝不阻断主流程)
    #[tokio::test]
    async fn test_describe_modules_recovers_from_corrupt_cache() {
        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
        use petgraph::stable_graph::StableDiGraph;

        let dir = std::env::temp_dir().join(format!("rw_desc_cache_corrupt_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join(".code-repo-wiki/.state")).unwrap();
        // 写入损坏的缓存文件(非法 JSON)
        std::fs::write(dir.join(".code-repo-wiki/.state/module_descriptions.json"), "{not-json").unwrap();

        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
        g.add_node(CodeNode {
            id: NodeId::new(0),
            kind: NodeKind::File,
            name: "net.rs".into(),
            file_path: Some("src/net.rs".into()),
            line_range: None,
            doc_comment: None,
            signature: None,
            visibility: None,
            module_path: vec!["src".into(), "net".into()],
        });
        let kg = KnowledgeGraph {
            graph: g,
            modules: vec![ModuleCluster {
                name: "src::net".into(),
                node_ids: vec![NodeId::new(0)],
                cohesion: 1.0,
                coupling: 0.0,
                description: None,
            }],
            features: Vec::new(),
        };
        let provider = MockProvider::new();
        let generator = WikiGenerator::new(&provider, 0);
        let config = crate::config::schema::WikiConfig {
            output_dir: Some(dir.join(".code-repo-wiki")),
            ..Default::default()
        };
        let root = crate::project::ProjectRoot::new(dir.clone());
        // 损坏缓存不应 panic,应走 LLM 重新生成并获得描述
        let modules = generator.describe_modules(&kg, "zh", &config, &root).await;
        assert!(modules[0].description.is_some(), "损坏缓存回退后应重新生成描述");
        assert!(generator.llm_call_count() > 0, "损坏缓存必须触发 LLM 调用");
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// 实体名额策略(t02 拍板):行为型优先排序 + 排除字段级(variable)
    #[test]
    fn test_collect_module_entity_names_prioritizes_behavior_over_fields() {
        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
        use petgraph::stable_graph::StableDiGraph;

        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
        let mut ids = Vec::new();
        // 打乱插入顺序:变量与常量在前、函数在后——验证排序把行为型提到名额前排
        for i in 0..8 {
            ids.push(g.add_node(CodeNode {
                id: NodeId::new(i as usize),
                kind: NodeKind::Variable,
                name: format!("field_{i}"),
                file_path: None,
                line_range: None,
                doc_comment: None,
                signature: None,
                visibility: None,
                module_path: vec!["src".into(), "net".into()],
            }));
        }
        for i in 0..5 {
            ids.push(g.add_node(CodeNode {
                id: NodeId::new(100 + i as usize),
                kind: NodeKind::Constant,
                name: format!("const_{i}"),
                file_path: None,
                line_range: None,
                doc_comment: None,
                signature: None,
                visibility: None,
                module_path: vec!["src".into(), "net".into()],
            }));
        }
        // 10 个函数 + 5 个结构体 + 1 个文件容器
        for i in 0..10 {
            ids.push(g.add_node(CodeNode {
                id: NodeId::new(200 + i as usize),
                kind: NodeKind::Function,
                name: format!("fn_{i:02}"),
                file_path: None,
                line_range: None,
                doc_comment: None,
                signature: None,
                visibility: None,
                module_path: vec!["src".into(), "net".into()],
            }));
        }
        for i in 0..5 {
            ids.push(g.add_node(CodeNode {
                id: NodeId::new(300 + i as usize),
                kind: NodeKind::Struct,
                name: format!("struct_{i}"),
                file_path: None,
                line_range: None,
                doc_comment: None,
                signature: None,
                visibility: None,
                module_path: vec!["src".into(), "net".into()],
            }));
        }
        ids.push(g.add_node(CodeNode {
            id: NodeId::new(999),
            kind: NodeKind::File,
            name: "net.rs".into(),
            file_path: None,
            line_range: None,
            doc_comment: None,
            signature: None,
            visibility: None,
            module_path: vec!["src".into(), "net".into()],
        }));
        let module = ModuleCluster {
            name: "src::net".into(),
            node_ids: ids,
            cohesion: 0.5,
            coupling: 0.5,
            description: None,
        };
        let graph = KnowledgeGraph {
            graph: g,
            modules: vec![module.clone()],
            features: Vec::new(),
        };

        let names = collect_module_entity_names(&module, &graph);
        // 总数 29(8 变量 + 5 常量 + 10 函数 + 5 结构体 + 1 容器,容器与字段排除)
        assert_eq!(names.len(), 20, "变量与容器不进入名额");
        assert!(
            !names.iter().any(|n| n.starts_with("field_")),
            "字段级实体(variable)应被排除"
        );
        assert!(
            !names.iter().any(|n| n == "net.rs"),
            "容器节点(File)应被排除"
        );
        // 行为型在前:函数全部先于结构体,结构体先于常量
        let fn_pos = names.iter().position(|n| n == "fn_00").unwrap();
        let struct_pos = names.iter().position(|n| n == "struct_0").unwrap();
        let const_pos = names.iter().position(|n| n == "const_0").unwrap();
        assert!(fn_pos < struct_pos && struct_pos < const_pos, "优先级序: 函数 < 结构体 < 常量");
        // 同级字典序(确定性):fn_00 在 fn_01 前
        assert!(names.iter().position(|n| n == "fn_00").unwrap() < names.iter().position(|n| n == "fn_01").unwrap());
    }

    /// 名额截断:实体数超过 DESCRIBE_ENTITY_CAP 时只保留前 30 个(行为型优先)
    #[test]
    fn test_collect_module_entity_names_caps_at_30() {
        use crate::model::{CodeEdge, CodeNode, ModuleCluster, NodeId, NodeKind};
        use petgraph::stable_graph::StableDiGraph;

        let mut g = StableDiGraph::<CodeNode, CodeEdge>::new();
        let mut ids = Vec::new();
        for i in 0..40 {
            ids.push(g.add_node(CodeNode {
                id: NodeId::new(i as usize),
                kind: NodeKind::Function,
                name: format!("fn_{i:02}"),
                file_path: None,
                line_range: None,
                doc_comment: None,
                signature: None,
                visibility: None,
                module_path: vec!["src".into()],
            }));
        }
        let module = ModuleCluster {
            name: "src".into(),
            node_ids: ids,
            cohesion: 1.0,
            coupling: 0.0,
            description: None,
        };
        let graph = KnowledgeGraph {
            graph: g,
            modules: vec![module.clone()],
            features: Vec::new(),
        };
        let names = collect_module_entity_names(&module, &graph);
        assert_eq!(names.len(), DESCRIBE_ENTITY_CAP);
        assert_eq!(names[0], "fn_00", "字典序稳定");
        assert_eq!(names[29], "fn_29", "截断取前 30");
    }

    /// 概览自底向上合成:overview_prompt 注入卡片摘要段(模块名+摘要+关键实体)
    #[test]
    fn test_overview_prompt_includes_card_summaries() {
        use crate::model::KnowledgeCard;

        let graph = KnowledgeGraph::default();
        let config = WikiConfig::default();
        let card = KnowledgeCard {
            module_name: "src::net".into(),
            module_type: "module".into(),
            summary: "网络模块:连接管理与监听".into(),
            key_entities: vec![crate::model::EntitySummary {
                name: "connect".into(),
                kind: "function".into(),
                visibility: "public".into(),
                doc: None,
                source: None,
            }],
            dependencies: vec![],
            dependents: vec![],
            design_patterns: vec![],
            todo_notes: vec![],
            related_files: vec![],
            coding_spec: None,
            tech_stack: vec![],
            architecture: None,
            pending_manual_edits: vec![],
            features: Vec::new(),
        };
        let prompt = overview_prompt(&[], &[card], &graph, &config);
        assert!(prompt.contains("## 模块卡片摘要"), "应含卡片摘要节");
        assert!(prompt.contains("src::net"), "应含模块名");
        assert!(prompt.contains("网络模块"), "应含卡片摘要");
        assert!(prompt.contains("connect"), "应含关键实体");
    }
}