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
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
pub mod config;
pub mod model;
pub mod ingest;
pub mod analysis;
pub mod generate;
pub mod output;
pub mod incremental;
pub mod search;
pub mod commands;
pub mod fs;
pub mod mcp;
pub mod project;
pub mod bench;
pub mod doctor;
pub mod key;

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

use std::sync::{Arc, OnceLock};
use tokio::runtime::Runtime;

use anyhow::{bail, Context};

/// 仓库分析结果,完整流水线的输出
pub struct AnalysisResult {
    pub graph: model::KnowledgeGraph,
    pub documents: Vec<model::WikiDocument>,
    pub cards: Vec<model::KnowledgeCard>,
    pub stats: AnalysisStats,
}

/// 分析统计信息
#[derive(Debug, Clone, Default)]
pub struct AnalysisStats {
    pub files_scanned: usize,
    pub files_parsed: usize,
    /// 扫描范围内解析失败的文件数(非 UTF-8 / tree-sitter 解析错误;
    /// B5 失败可观测——此前失败文件仅在日志出现,统计无法反映)
    pub files_failed: usize,
    pub total_entities: usize,
    pub total_edges: usize,
    pub modules_detected: usize,
    pub generation_time_ms: u64,
    /// 本次生成失败被隔离的模块名(卡片或页面生成失败,v22 修复):
    /// 写入生成状态供下次 update 补偿重试;也供调用方(doctor/报告)观测
    pub failed_modules: Vec<String>,
}

/// 全局 tokio 运行时(流水线与 MCP server 共用,避免重复初始化)
pub fn get_global_runtime() -> &'static Arc<Runtime> {
    static RT: OnceLock<Arc<Runtime>> = OnceLock::new();
    RT.get_or_init(|| Arc::new(Runtime::new().expect("创建 tokio Runtime 失败")))
}

/// 加载配置,并用 CLI 传入的 output 路径覆盖配置文件中的 output.dir
///
/// output.dir 是相对路径(默认 .code-repo-wiki),覆盖后渲染、搜索索引、状态目录
/// 等所有下游引用自然指向新目录。
/// 加载配置并统一注入输出目录(v30:output.dir 已硬编码,运行时注入
/// `--output` 覆盖或 root 化绝对路径,见 schema::WikiConfig::output_dir)。
fn load_config_with_output(
    config_path: Option<&Path>,
    output: Option<&Path>,
    root: &project::ProjectRoot,
) -> anyhow::Result<config::schema::WikiConfig> {
    // v25:None 走默认配置链(项目级 config.toml 字段级合并覆盖用户级
    // config.toml,见 config::load_default_config);Some 为显式
    // --config 单文件原样加载
    let mut config = match config_path {
        Some(p) => config::load_config(p)?,
        None => config::load_default_config(root)?.1,
    };
    // root 统一(v17 F 组,t09 实测发现):输出目录默认相对路径
    // (.code-repo-wiki)时必须解析到 root,否则 --root 场景(cwd ≠ root)
    // 产物写到进程 cwd 错位。--output 覆盖与 root 化都注入运行时字段,
    // 下游统一走 config.output_dir()(见 schema.rs 注释)。
    let output_dir = match output {
        Some(out) => root.path().join(out),
        None => root.path().join(crate::config::schema::OUTPUT_DIR),
    };
    config.output_dir = Some(output_dir);
    Ok(config)
}

/// 加载配置并统一解析 output.dir 相对路径到 root(main.rs 各命令入口用;
/// 与 run_pipeline 内部的 load_config_with_output 同源,保证 CLI 层与
/// pipeline 层对产物目录的解析一致)
pub fn load_config_rooted(
    config_path: Option<&Path>,
    root: &project::ProjectRoot,
) -> anyhow::Result<config::schema::WikiConfig> {
    load_config_with_output(config_path, None, root)
}

/// 加载保护集:旧 state 的 protected_docs + 新检测出的人工修改;force 时清空
///
/// 损坏语义(票 02,fail-loud 裁决):state 文件不存在 = 合法首次运行
/// (返回空保护);存在但读取/解析失败 = 状态损坏(半截写、被外部
/// 改动)——损坏状态会让 protected_docs 静默丢失,人工修改保护失效,
/// 故显式报错中断(与 sync_from_git 对损坏状态的拒绝行为一致),
/// 由用户删除 .state/ 后重新 generate 重建。
fn load_protection(
    config: &config::schema::WikiConfig,
    force: bool,
) -> anyhow::Result<(std::collections::HashSet<String>, Option<incremental::state::GenerationState>)> {
    if force {
        return Ok((std::collections::HashSet::new(), None));
    }
    let state_dir = config.output_dir().join(".state");
    let state_path = state_dir.join("generation_state.json");
    if !state_path.exists() {
        // 无状态文件 = 从未生成过,合法空保护
        return Ok((std::collections::HashSet::new(), None));
    }
    let state = incremental::state::GenerationState::load(&state_dir).with_context(|| {
        format!(
            "状态文件损坏或不可读: {}(删除该文件后重新运行 generate 可重建)",
            state_path.display()
        )
    })?;
    let mut protected: std::collections::HashSet<String> = state
        .protected_docs
        .iter()
        .cloned()
        .collect();
    for p in state.detect_manually_modified() {
        protected.insert(p);
    }
    Ok((protected, Some(state)))
}

/// 保存生成状态:doc_fingerprints 只记录实际写盘的文档(跳过保护集),
/// protected_docs 合并本次保护集写回;failed_modules 记录本次失败隔离的
/// 模块(v22:下次 update 并入变更集重试,防止失败模块永远无法补生成)
///
/// 8 个参数均为不同来源的独立输入(无共享结构可归并),与
/// generate_global_documents 同一例外模式,保留平铺参数。
#[allow(clippy::too_many_arguments)]
fn save_generation_state(
    root: &project::ProjectRoot,
    config: &config::schema::WikiConfig,
    insights: &[ingest::parser::FileInsight],
    documents: &[model::WikiDocument],
    cards: &[model::KnowledgeCard],
    protected: &std::collections::HashSet<String>,
    commit_hash: &str,
    failed_modules: &[String],
) {
    let output_dir = config.output_dir();
    let state_dir = output_dir.join(".state");
    // t02/P1-2:三处落盘失败全部告警(此前静默——状态写失败会导致下次 update
    // 无指纹基线,人工修改保护与反向同步**静默失效**,与模块头"不静默丢失保护"
    // 的目标矛盾;与 incremental/mod.rs 前置保存的 warn 处理对齐)。
    match incremental::state::GenerationState::from_insights(root, insights, commit_hash) {
        Ok(mut state) => {
            state.failed_modules = failed_modules.to_vec();
            let mut protected_docs: Vec<String> = protected.iter().cloned().collect();
            protected_docs.sort();
            state.protected_docs = protected_docs;
            match incremental::state::GenerationState::record_doc_fingerprints(
                documents,
                cards,
                output_dir,
                &output::wiki_languages(config),
            ) {
                Ok((fps, modules)) => {
                    // 全量记录指纹与模块归属(含保护集文档):受保护文档本轮被跳过
                    // 写盘,磁盘上仍是人工版,记录的即人工版指纹——下次再被人为修改
                    // 时指纹比对仍能命中检测,反向同步可持续生效;卡片侧的记录注入
                    // 自带去重(contains 检查),同一修改不会重复同步。
                    state.doc_fingerprints = fps;
                    state.doc_modules = modules;
                }
                Err(e) => tracing::warn!(
                    "产物指纹记录失败(下次 update 人工修改检测可能失效): {e}"
                ),
            }
            if let Err(e) = state.save(&state_dir) {
                tracing::warn!("生成状态保存失败(下次 update 无指纹基线,人工修改保护失效): {e}");
            }
        }
        Err(e) => tracing::warn!("生成状态构造失败(本次状态未落盘): {e}"),
    }
}

/// 流水线进度事件(供 CLI --progress-json 与插件进度展示使用)
#[derive(Debug, Clone, Copy)]
pub struct ProgressEvent {
    /// 阶段名:scanning/analyzing/chunking/cards/wiki/output/index/done
    pub stage: &'static str,
    /// 进度百分比(0-100)
    pub percent: u8,
}

/// 生成模式(票 12:双流水线合并为单入口的 mode 区分)
///
/// - `Full`:全量扫描解析 + 全量 LLM 生成 + 全量索引重建(generate 命令)
/// - `Incremental`:parse 层增量(解析缓存)+ 过滤生成 + 增量索引(update 命令)
#[derive(Debug, Clone)]
pub enum GenerationMode {
    Full,
    Incremental {
        /// 外部监听事件路径(watch 传入;普通增量更新传空)
        watch_paths: Vec<std::path::PathBuf>,
        /// 监听事件携带的变更类型(Deleted 直入删除清理)
        change_kind: Option<incremental::watch::ChangeKind>,
    },
}

/// 运行完整的分析流水线(配置文件路径)
///
/// `output` 非空时覆盖配置文件中的 output.dir(对应 CLI 的 --output 参数),
/// 后续渲染、搜索索引、状态目录全部使用覆盖后的值。
/// `force` 为 true 时清空人工修改保护集并覆盖所有文档(对应 CLI 的 --force)。
/// `root` 为项目根(扫描根 + git 定位 + watch 根的注入载体,--root 参数)
/// `mode` 区分全量生成与增量更新(两者共享本函数的主干,差异点在
/// 扫描缓存、变更分析、生成过滤、索引更新四处)。
pub fn run_pipeline(
    config_path: Option<&Path>,
    output: Option<&Path>,
    force: bool,
    root: &project::ProjectRoot,
    mode: &GenerationMode,
) -> anyhow::Result<AnalysisResult> {
    run_pipeline_with_progress(config_path, output, force, root, mode, &|_| {})
}

/// 生成流水线分段计时(v32 8.1 FR-301 数据驱动剖析)
///
/// 各段毫秒:扫描/解析、图构建、增量分析、分块、卡片生成、Wiki 页生成、
/// 阅读指南、渲染写盘、搜索索引、状态保存。由 run_pipeline_with_progress
/// 收集(chunk/card/wiki 三段的内部值来自 generate::GenerationOutput.timings),
/// 完成时写入 .state/last_timings.json 供 bench 回放后读取——评测可定位
/// 大仓各阶段瓶颈(cal.com mock 372s 先例)。.state 非产物页,写入不影响
/// test_determinism 内容级哈希;serde 全默认,文件缺失/损坏按 None 处理。
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct GenerationTimings {
    pub scan_parse_ms: u64,
    pub graph_ms: u64,
    pub incremental_ms: u64,
    pub chunk_ms: u64,
    pub card_ms: u64,
    pub wiki_ms: u64,
    pub index_guide_ms: u64,
    pub render_ms: u64,
    pub index_ms: u64,
    pub state_ms: u64,
    pub total_ms: u64,
}

/// 写入最近一次生成的分段计时(v32 8.1)
///
/// bench 的 measure_update_recall 回放生成后读取该文件以获得各段耗时。
/// 写失败只告警(计时是诊断信息,不阻断主流程)。
pub(crate) fn write_last_timings(config: &config::schema::WikiConfig, timings: &GenerationTimings) {
    let state_dir = config.output_dir().join(".state");
    if let Err(e) = std::fs::create_dir_all(&state_dir) {
        tracing::warn!("分段计时目录创建失败: {e}");
        return;
    }
    let path = state_dir.join("last_timings.json");
    match serde_json::to_string_pretty(timings) {
        Ok(text) => {
            if let Err(e) = std::fs::write(&path, text) {
                tracing::warn!("分段计时写盘失败: {e}");
            }
        }
        Err(e) => tracing::warn!("分段计时序列化失败: {e}"),
    }
}

/// 运行完整的分析流水线,并在各阶段边界回调进度事件
///
/// 事件点:scanning 10 / analyzing 25 / chunking 30 / cards 60 / wiki 90 /
/// output 95 / index 98 / done 100,对应扫描、分析、生成、渲染、索引、保存阶段。
pub fn run_pipeline_with_progress(
    config_path: Option<&Path>,
    output: Option<&Path>,
    force: bool,
    root: &project::ProjectRoot,
    mode: &GenerationMode,
    on_progress: &dyn Fn(ProgressEvent),
) -> anyhow::Result<AnalysisResult> {
    let config = load_config_with_output(config_path, output, root)?;
    // v36 D4:单实例运行锁——并发 generate/update/watch 会把状态/索引/
    // 产物互相覆盖(最后写入者胜)。锁作用域=本次生成全程(Drop 释放),
    // 崩溃残留由报错指引人工删除(不自动清,见 fs.rs acquire_run_lock)。
    let _run_lock = crate::fs::acquire_run_lock(&config)?;
    let _span = tracing::info_span!("pipeline", config = %config_path.map(|p| p.display().to_string()).unwrap_or_else(|| "默认链".into()));
    let _enter = _span.enter();
    let start = std::time::Instant::now();
    // v32 8.1:分段计时收集(各阶段边界打点;chunk/card/wiki 内部段在
    // generate 侧计时,见 GenerationOutput.timings)
    let mut timings = GenerationTimings::default();
    let mut is_incremental = matches!(mode, GenerationMode::Incremental { .. });
    // U06/D11:force 语义补全——force 时无论增量/全量都按全量重生成。
    // 旧实现 force 只清保护集,增量仍按 diff 过滤生成,未变更的文档
    // 不会被重生成,"force 覆盖所有文档"(本函数顶部注释)名不副实。
    if force && is_incremental {
        tracing::info!("--force 与增量模式同时使用:退化为全量重生成");
        is_incremental = false;
    }

    // 保护集:旧 state 的 protected_docs + 检测出的人工修改;force 时清空。
    // old_state 同时供人工修改反向同步组装(collect_manual_edits → 生成前注入)
    let (protected, old_state) = load_protection(&config, force)?;

    // v19 t06:no-op 快速跳过(OpenWiki git-head 模式)——增量模式且上次
    // 成功生成到同一 commit 且源码工作树无未提交变更(产物目录除外)且
    // 产物存在时,在扫描之前直接跳过(定时 CI/watch 免费空转;判定细节
    // 与保守边界见 incremental::should_skip_noop 注释)。与下方"无代码
    // 变更短路"同一出口:人工修改反向同步照常执行。
    if is_incremental && incremental::should_skip_noop(root, &config)? {
        tracing::info!("无文件变更,跳过更新(no-op 快速判定)");
        if let Some(state) = &old_state {
            let synced = sync_manual_edits_to_cards(&config, state)?;
            if synced > 0 {
                tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
            }
        }
        let stats = AnalysisStats {
            files_scanned: 0,
            generation_time_ms: start.elapsed().as_millis() as u64,
            ..Default::default()
        };
        return Ok(AnalysisResult {
            graph: model::KnowledgeGraph::default(),
            documents: Vec::new(),
            cards: Vec::new(),
            stats,
        });
    }

    // Phase 1: 扫描。增量模式启用解析缓存(parse 层增量:内容指纹未变复用
    // 缓存结果,仅变更文件重新 tree-sitter 解析);全量模式直接全量解析。
    let watch_list: Vec<std::path::PathBuf> = match mode {
        GenerationMode::Incremental { watch_paths, .. } => watch_paths.clone(),
        GenerationMode::Full => Vec::new(),
    };
    // 事件路径统一相对化(相对项目根):scan 产出的 insight 路径已是相对
    // 扫描根,watch 层外部传入的路径必须对齐同一基准,否则路径比较
    // (缓存判定/变更集判定)对绝对路径恒不命中。
    let watch_paths: Vec<std::path::PathBuf> = watch_list
        .iter()
        .map(|p| p.strip_prefix(root.path()).map(|r| r.to_path_buf()).unwrap_or_else(|_| p.clone()))
        .collect();
    let watch_set: std::collections::HashSet<std::path::PathBuf> =
        watch_paths.iter().cloned().collect();
    let scan = if is_incremental {
        let cache_path = config.output_dir().join(".state").join("insights_cache.json");
        ingest::scan_and_parse_cached_at(root, &Some(cache_path), &watch_set)?
    } else {
        ingest::scan_and_parse_at(root)?
    };
    let file_insights = scan.insights;
    let files_failed = scan.files_failed;
    timings.scan_parse_ms = start.elapsed().as_millis() as u64;
    on_progress(ProgressEvent { stage: "scanning", percent: 10 });
    if file_insights.is_empty() {
        bail!("未找到任何源文件");
    }
    let mut stats = AnalysisStats {
        files_scanned: file_insights.len(),
        files_parsed: file_insights.iter().filter(|f| !f.entities.is_empty()).count(),
        files_failed,
        ..Default::default()
    };

    // Phase 2: 分析(build_graph 内部完成模块检测并写回 graph.modules,
    // 此处直接读结果供 stats,不重复运行检测)
    let mut graph = analysis::build_graph(&file_insights)?;
    attach_features(&mut graph, &config);
    timings.graph_ms = start.elapsed().as_millis() as u64 - timings.scan_parse_ms;
    on_progress(ProgressEvent { stage: "analyzing", percent: 25 });
    stats.total_entities = graph.graph.node_count();
    stats.total_edges = graph.graph.edge_count();
    stats.modules_detected = graph.modules.len();

    // Phase 2b: 增量变更分析(git diff + 实体级变化分类 + 语义传播;
    // 全量模式跳过。diff 超限/非 git 仓库时内部回退全量语义)
    let inc_result = if is_incremental {
        Some(incremental::run_incremental_update_at(root, &file_insights, &graph, &config, &watch_paths)?)
    } else {
        None
    };
    timings.incremental_ms = start.elapsed().as_millis() as u64
        - timings.scan_parse_ms
        - timings.graph_ms;

    // 无代码变更短路:仅增量模式存在;此时若有新检测的人工修改仍需
    // 反向同步到卡片文件(生成路径跳过时此处的直接写盘是唯一落卡途径,
    // 记录在下次有变更的生成时经 extract_pending_manual_edits 注入 LLM 输入)
    if let Some(inc) = &inc_result
        && inc.changed_files.is_empty()
    {
        if let Some(state) = &old_state {
            let synced = sync_manual_edits_to_cards(&config, state)?;
            if synced > 0 {
                tracing::info!("人工修改已反向同步到 {} 张卡片", synced);
            }
        }
        tracing::info!("无变更,跳过生成");
        let stats = AnalysisStats {
            files_scanned: file_insights.len(),
            generation_time_ms: start.elapsed().as_millis() as u64,
            ..Default::default()
        };
        return Ok(AnalysisResult {
            graph,
            documents: Vec::new(),
            cards: Vec::new(),
            stats,
        });
    }

    // Phase 3: 生成(需要 tokio 运行时)。人工修改记录在生成前注入
    // LLM 输入(collect_manual_edits:旧状态指纹比对 + 模块归属精确匹配)。
    // 增量模式只对变更文件 + 语义传播判定的受影响模块过滤生成
    //(run_generation_filtered),全量模式全量生成。
    on_progress(ProgressEvent { stage: "chunking", percent: 30 });
    let rt = get_global_runtime();
    let extra_edits = collect_manual_edits(old_state.as_ref());
    let mut gen_output = if let Some(inc) = &inc_result {
        rt.block_on(generate::run_generation_filtered(
            &graph, &file_insights, &config, root, inc, &extra_edits,
        ))?
    } else {
        rt.block_on(generate::run_generation(&graph, &file_insights, &config, root, &extra_edits))?
    };
    on_progress(ProgressEvent { stage: "cards", percent: 60 });
    // v32 8.1:generate 侧内部段(chunk/card/wiki)合并进总计时
    timings.chunk_ms = gen_output.timings.chunk_ms;
    timings.card_ms = gen_output.timings.card_ms;
    timings.wiki_ms = gen_output.timings.wiki_ms;

    // Phase 3b: 阅读指南 index.md(仅主语言,写盘路径 wiki/{主语言}/index.md)。
    // LLM 失败重试 1 次仍失败 → 降级确定性骨架(模块入度中心度降序的链接列表);
    // provider 构建失败(理论不可达:run_generation 已保证 LLM 配置可用)同样降级。
    // 错误处理与全局文档(架构/概览)一致:失败只告警,不中断主流程。
    //
    // U04/D8 增量门控:受影响模块为空(纯实现级变更)时 index 内容(模块列表
    // + 描述)不会变化,从导出快照回填旧 index(零 LLM 调用),与架构/概览的
    // backfill 语义一致;快照不可用(首次增量/损坏)时回退正常生成。
    // v21 验证轮:含已删除文件时**不放行**回填——纯删除场景 index 必须
    // 重生成,否则模块列表继续列出已删模块(与架构/概览的 has_deleted_files
    // 例外同一语义)。
    let gated = if is_incremental
        && inc_result
            .as_ref()
            .is_some_and(|i| i.affected_modules.is_empty() && !i.has_deleted_files)
    {
        generate::backfill_global_docs(
            &config,
            &mut gen_output.documents,
            &[crate::model::DocumentKind::TableOfContents],
        )
    } else {
        false
    };
    if !gated {
        let index_doc = match generate::create_provider(&config) {
            Ok(provider) => rt.block_on(generate::index::generate_index_guide(
                &provider,
                &graph,
                &gen_output.cards,
                &config,
            )),
            Err(e) => {
                tracing::warn!("阅读指南 LLM 不可用,降级为确定性骨架: {e}");
                generate::index::fallback_index_guide(&graph, &config)
            }
        };
        gen_output.documents.push(index_doc);
    }
    timings.index_guide_ms = start.elapsed().as_millis() as u64
        - timings.scan_parse_ms
        - timings.graph_ms
        - timings.incremental_ms
        - timings.chunk_ms
        - timings.card_ms
        - timings.wiki_ms;

    // v17 t06:mock 模式告警——占位内容页脚标注(产物可辨识,防误读为
    // 真实文档)。mock 产物的页面内容是占位 JSON(MockProvider 固定返回),
    // 生成层追加页脚(render 层保持纯渲染不感知 provider 类型;合成页
    // api.md 的注入在 render_all 内,共用 MOCK_FOOTER_MARK 单一来源)。
    if matches!(
        config.llm.provider,
        crate::config::schema::LlmProviderType::Mock
    ) {
        tracing::warn!("使用 mock provider:产物为占位内容,非真实文档(仅供测试/CI 演示)");
        for doc in &mut gen_output.documents {
            // 幂等追加:纯删除场景(增量快照回填)的旧文档已含页脚,
            // 重复注入会产出双页脚——已以页脚结尾的跳过(v21 F 组实测)
            if !doc.content.ends_with(crate::output::MOCK_FOOTER_MARK) {
                doc.content.push_str(crate::output::MOCK_FOOTER_MARK);
            }
        }
    }

    // Phase 4: 输出(render_all 内部同步写导出快照;产物集合 diff 清理
    // 全量/增量统一:旧状态记录过但本次未生成的产物(含已删模块的
    // 旧页面/卡片)一律清理,module_{n} 档不再漏删)
    on_progress(ProgressEvent { stage: "wiki", percent: 90 });
    output::render_all(&gen_output.documents, &gen_output.cards, &graph, &config, &protected)?;
    // 保留集 = 当前扫描的全部模块(graph.modules 基于全部 insights 检测,
    // 含增量未受影响的模块):增量只重新生成受影响模块,未受影响模块的
    // 旧页面须保留(v17 F 组,t09 实测修复——误删会制造断链)
    let preserved_modules: std::collections::HashSet<String> = graph
        .modules
        .iter()
        .map(|m| m.name.clone())
        .collect();
    cleanup_stale_outputs(
        old_state.as_ref(),
        &output::rendered_paths(&gen_output.documents, &gen_output.cards, &config),
        &preserved_modules,
    );
    timings.render_ms = start.elapsed().as_millis() as u64
        - timings.scan_parse_ms
        - timings.graph_ms
        - timings.incremental_ms
        - timings.chunk_ms
        - timings.card_ms
        - timings.wiki_ms
        - timings.index_guide_ms;
    on_progress(ProgressEvent { stage: "output", percent: 95 });

    // Phase 5: 构建/增量更新搜索索引
    let index_result = if is_incremental {
        let changed_set: std::collections::HashSet<std::path::PathBuf> = inc_result
            .as_ref()
            .map(|i| i.changed_files.iter().cloned().collect())
            .unwrap_or_default();
        update_search_index_incremental(&graph, &file_insights, &config, &changed_set)
    } else {
        build_search_index(&graph, &file_insights, &config)
    };
    if let Err(e) = index_result {
        tracing::warn!("搜索索引构建失败(不影响主流程): {}", e);
    }
    timings.index_ms = start.elapsed().as_millis() as u64
        - timings.scan_parse_ms
        - timings.graph_ms
        - timings.incremental_ms
        - timings.chunk_ms
        - timings.card_ms
        - timings.wiki_ms
        - timings.index_guide_ms
        - timings.render_ms;
    on_progress(ProgressEvent { stage: "index", percent: 98 });

    // Phase 6: 保存增量状态(含文档指纹用于人工修改保护)
    // A3(v14):git 基线获取失败显式区分——非 git 仓库(info:预期
    // 场景,无基线则状态不推进、下次 update 回退全量)与 git 仓库内
    // 失败(warn:仓库损坏/无 HEAD/HEAD 无目标等)。此前 unwrap_or_default
    // 把两者混为一谈静默吞掉,git 命令失败时用户无从知晓状态为何不推进。
    let head_hash = match incremental::diff::get_head_commit_hash_at(root) {
        Ok(h) => h,
        Err(e) => {
            if e.downcast_ref::<git2::Error>()
                .map(|g| g.code() == git2::ErrorCode::NotFound)
                .unwrap_or(false)
            {
                tracing::info!("非 git 仓库,无 git 基线(增量状态不推进): {}", e);
            } else {
                tracing::warn!("获取 git HEAD 失败(增量状态不推进): {}", e);
            }
            String::new()
        }
    };
    save_generation_state(root, &config, &file_insights, &gen_output.documents, &gen_output.cards, &protected, &head_hash, &gen_output.generation_stats.failed_modules);

    timings.state_ms = start.elapsed().as_millis() as u64
        - timings.scan_parse_ms
        - timings.graph_ms
        - timings.incremental_ms
        - timings.chunk_ms
        - timings.card_ms
        - timings.wiki_ms
        - timings.index_guide_ms
        - timings.render_ms
        - timings.index_ms;
    timings.total_ms = start.elapsed().as_millis() as u64;
    write_last_timings(&config, &timings);

    on_progress(ProgressEvent { stage: "done", percent: 100 });
    stats.generation_time_ms = start.elapsed().as_millis() as u64;
    // 展示用统计(失败模块真源在 generation_stats;save 调用已直接使用
    // generation_stats.failed_modules——顺序修复:此前在此处才赋值,晚于
    // Phase 6 的 save_generation_state,导致失败模块恒为空数组落盘,
    // v22 补偿机制对全量 generate 的失败静默失效(v23 C 组实测发现))
    stats.failed_modules = gen_output.generation_stats.failed_modules.clone();
    tracing::info!("流水线完成: {} 个文件, {} 个实体, {} 条边, {} 个模块, 耗时 {}ms",
        stats.files_scanned, stats.total_entities, stats.total_edges,
        stats.modules_detected, stats.generation_time_ms);

    Ok(AnalysisResult {
        graph,
        documents: gen_output.documents,
        cards: gen_output.cards,
        stats,
    })
}

/// 知识卡片操作(CLI card 子命令与 Qoder /knowledge 对等)
pub fn run_card_command(
    config_path: Option<&Path>,
    root: &project::ProjectRoot,
    action: &generate::card::CardAction,
) -> anyhow::Result<()> {
    let config = load_config_with_output(config_path, None, root)?;
    // 编辑类动作要求卡片已存在:先校验(错误信息优先于 LLM API Key 检查)
    match action {
        generate::card::CardAction::Generate { .. } => {}
        generate::card::CardAction::Modify { module, .. }
        | generate::card::CardAction::Supplement { module, .. }
        | generate::card::CardAction::Rewrite { module, .. } => {
            if generate::card::read_card(&config, module)?.is_none() {
                anyhow::bail!("模块 {module} 的卡片不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki card generate {module}` 生成");
            }
        }
    }
    let provider = generate::create_provider(&config)?;
    let rt = get_global_runtime();
    match action {
        generate::card::CardAction::Generate { module } => {
            rt.block_on(generate::card::generate_module_card(&provider, &config, root, module))
        }
        generate::card::CardAction::Modify { module, instruction, references } => {
            rt.block_on(generate::card::edit_card(
                &provider, &config, module, instruction, references,
                generate::card::CardEditMode::Modify,
            ))
        }
        generate::card::CardAction::Supplement { module, instruction, references } => {
            rt.block_on(generate::card::edit_card(
                &provider, &config, module, instruction, references,
                generate::card::CardEditMode::Supplement,
            ))
        }
        generate::card::CardAction::Rewrite { module, instruction, references } => {
            rt.block_on(generate::card::edit_card(
                &provider, &config, module, instruction, references,
                generate::card::CardEditMode::Rewrite,
            ))
        }
    }
}


/// 清理过期产物(票 10:产物集合 diff 语义,全量/增量统一)
///
/// 语义:状态中记录过的旧产物路径(doc_fingerprints/doc_modules 键,即
/// 上次生成写盘的 wiki 页与卡片全集)减去本次实际生成的产物集合
/// (output::rendered_paths:含受保护文档路径——受保护文档属于生成集,
/// 磁盘上是人工版,diff 后天然不在待删集合,不会误删人工编辑内容)。
/// 差集 = 已消失模块/重命名模块的旧产物,一律删除。
///
/// 与旧实现(cleanup_deleted_outputs 按被删文件路径推导模块名)相比:
/// 不依赖模块名路径推导,module_{n}(无目录社区)档不再漏删;全量
/// generate 也清理旧产物(旧实现仅增量路径调用)。
///
/// 删除失败显式告警(文件被占用等),不静默吞错。
pub(crate) fn cleanup_stale_outputs(
    old_state: Option<&incremental::state::GenerationState>,
    rendered: &[std::path::PathBuf],
    preserved_modules: &std::collections::HashSet<String>,
) {
    let Some(state) = old_state else {
        return; // 无旧状态(首次生成):不存在可清理的旧产物
    };
    let mut stale: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
    stale.extend(state.doc_fingerprints.keys().map(String::as_str));
    stale.extend(state.doc_modules.keys().map(String::as_str));
    let rendered_set: std::collections::BTreeSet<String> = rendered
        .iter()
        .map(|p| p.to_string_lossy().to_string())
        .collect();
    let mut removed = 0usize;
    for path in stale {
        if rendered_set.contains(path) {
            continue;
        }
        // v17 F 组(t09 实测):root 统一(output.dir 绝对化)后,旧状态
        // 键可能仍是相对路径(迁移前的生成记录)——相对键无法与绝对
        // rendered 集可靠比较(旧 cwd 已不可考),保守保留,避免把合成页
        // 等无模块归属的产物误删(实测:api/architecture/index/overview
        // 四页被误删)。一次全量生成后状态键全部更新为绝对,后续增量
        // 的清理语义恢复正常(收敛点明确,非兜底)。
        if Path::new(path).is_relative() {
            continue;
        }
        // v17 F 组(t09 实测修复):增量模式下本次只重新生成受影响模块,
        // 未受影响模块的旧页面是**有效产物**(源码仍在),不能当过期
        // 清理——否则引用它的页面断链(实测:src_fs.md 被清理后 6 页
        // broken)。判据:该页面归属的模块仍在当前扫描结果中(preserved
        // 集合来自 graph.modules——基于全部 insights 的模块检测,未受
        // 影响模块也在内)→ 保留;模块已从扫描消失(源文件删除)→ 清理。
        if state
            .doc_modules
            .get(path)
            .is_some_and(|m| preserved_modules.contains(m))
        {
            continue;
        }
        let p = Path::new(path);
        if p.exists() {
            match std::fs::remove_file(p) {
                Ok(()) => removed += 1,
                Err(e) => tracing::warn!("清理过期产物失败 {}: {}", p.display(), e),
            }
        }
    }
    if removed > 0 {
        tracing::info!("清理过期产物 {} 个", removed);
    }
}

/// 组装"人工修改 → 卡片记录"映射(模块名 → 记录文本列表)
///
/// 官方语义:"人工修改反向同步到对应知识卡片"——被人工编辑过的页面不
/// 被自动更新覆盖,且修改被记录到卡片,下次生成时作为 LLM 输入提示。
///
/// 来源 = 状态中指纹不匹配的产物路径(detect_manually_modified)+ 其模块
/// 归属(doc_modules 精确映射:产物路径 → 模块名)。精确匹配杜绝了旧实现
/// stem 匹配在模块名含下划线时(src::foo_bar vs src::foo::bar 均压平为
/// src_foo_bar)的串卡片歧义;无模块归属的全局文档(api/overview/toc)跳过。
/// 记录在生成层(CardGenerator::generate_all_cards)于 LLM 输入前合并注入。
pub fn collect_manual_edits(
    state: Option<&incremental::state::GenerationState>,
) -> HashMap<String, Vec<String>> {
    let mut out: HashMap<String, Vec<String>> = HashMap::new();
    let Some(state) = state else { return out };
    for path in state.detect_manually_modified() {
        let Some(module) = state.doc_modules.get(&path) else {
            continue;
        };
        let summary = std::fs::read_to_string(&path)
            .map(|content| content.chars().take(200).collect::<String>())
            .unwrap_or_default();
        let note = format!("人工修改待同步: {path} 内容摘要: {summary}");
        out.entry(module.clone()).or_default().push(note);
    }
    out
}

/// 将检测到的人工修改记录直接同步到磁盘卡片(无代码变更时的反向同步路径)
///
/// 生成路径(有代码变更)由 CardGenerator 在 LLM 输入前注入记录并随卡片
/// 重写落盘;本函数覆盖"无代码变更但有人工修改"的场景——update 因
/// changed_files 为空而跳过生成时,人工修改记录也必须落到卡片文件:
/// 读现有卡片文本 → 合并记录(去重,含已有"人工修改待同步"节时在节内
/// 追加,否则在文件末尾新建节)→ 重写。记录下次生成时经
/// extract_pending_manual_edits 恢复为 LLM 输入,两条路径最终都收敛于
/// 卡片文件,保证反向同步语义在任何更新形态下都不丢。
pub fn sync_manual_edits_to_cards(
    config: &config::schema::WikiConfig,
    state: &incremental::state::GenerationState,
) -> anyhow::Result<usize> {
    let edits = collect_manual_edits(Some(state));
    if edits.is_empty() {
        return Ok(0);
    }
    let mut synced = 0usize;
    for (module, notes) in &edits {
        let card_path =
            output::card_page_path(config.output_dir(), &config.wiki.language, module);
        // 卡片读取失败(含不存在/损坏/权限)显式告警并跳过该卡片——
        // 原实现 unwrap_or_default 会把"读不到"当作"空卡片",随后追加
        // 人工修改节写盘,凭空重建被删除的卡片,且吞掉损坏错误。
        let mut content = match std::fs::read_to_string(&card_path) {
            Ok(c) => c,
            Err(e) => {
                tracing::warn!("读取卡片失败,跳过人工修改反向同步 {}: {}", card_path.display(), e);
                continue;
            }
        };
        let mut changed = false;
        for note in notes {
            if content.contains(note.as_str()) {
                continue;
            }
            changed = true;
            if let Some(section) = content.find("## 人工修改待同步") {
                // 节内追加:定位节后第一个空白行(节标题与列表之间)
                let insert_at = content[section..]
                    .find("\n\n")
                    .map(|i| section + i + 2)
                    .unwrap_or(content.len());
                content.insert_str(insert_at, &format!("- {note}\n"));
            } else {
                content.push_str(&format!("\n## 人工修改待同步\n\n- {note}\n"));
            }
        }
        if changed {
            crate::fs::write_file_atomic(&card_path, &content)?;
            synced += 1;
        }
    }
    Ok(synced)
}

/// 启动文件监听模式
///
/// `root` 为注入的项目根:首次全量生成与监听根均以它为基准
/// (扫描根一致,watch 常驻进程的 cwd 漂移不影响监听范围)。
pub fn run_watch(config_path: Option<&Path>, root: &project::ProjectRoot) -> anyhow::Result<()> {
    // 配置在此 fail-fast 校验(无效配置提前报错);监听循环本身不再读取配置
    let _config = match config_path {
        Some(p) => config::load_config(p)?,
        None => config::load_default_config(root)?.1,
    };
    tracing::info!("首次全量生成...");
    run_pipeline(config_path, None, false, root, &GenerationMode::Full)?;
    tracing::info!("全量生成完成,开始监听文件变化...");

    let config_path = config_path.map(|p| p.to_path_buf());
    // 监听根 = 注入的项目根(与 scan_and_parse_at 的扫描根一致)
    let watch_root = root.path().to_path_buf();
    let watch_root_for_loop = watch_root.clone();
    // v14 F 组(t06 拍板):Ctrl-C 优雅退出——专用线程等待 SIGINT 后置
    // 停止标记;run_watch_loop 每 500ms 轮询标记,置位时等当前增量
    // 生成完成再退出(不会在状态落盘中途打断)。
    let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    {
        let flag = stop_flag.clone();
        let rt = get_global_runtime();
        std::thread::spawn(move || {
            rt.block_on(async {
                let _ = tokio::signal::ctrl_c().await;
                flag.store(true, std::sync::atomic::Ordering::Relaxed);
                tracing::info!("收到 Ctrl-C,等待当前增量更新完成后退出...");
            });
        });
    }
    incremental::watch::run_watch_loop(
        &watch_root_for_loop,
        stop_flag,
        move |events| {
            for event in events {
                tracing::info!(
                    "检测到 {:?} {} 个文件变更,触发增量更新...",
                    event.kind,
                    event.paths.len()
                );
                // 事件类型显式传递:Deleted 直入删除清理(pipeline 内处理),
                // 其余 kind 走常规增量更新
                let change_kind = (event.kind == incremental::watch::ChangeKind::Deleted)
                    .then_some(event.kind);
                let root = project::ProjectRoot::new(watch_root.clone());
                let mode = GenerationMode::Incremental {
                    watch_paths: event.paths.clone(),
                    change_kind,
                };
                if let Err(e) = run_pipeline(config_path.as_deref(), None, false, &root, &mode) {
                    tracing::error!("增量更新失败: {}", e);
                } else {
                    tracing::info!("增量更新完成");
                }
            }
        },
    )
}

// ==================== 搜索索引集成 ====================

/// 获取搜索索引目录的绝对路径
fn search_index_dir(config: &config::schema::WikiConfig) -> std::path::PathBuf {
    config.output_dir().join(config::schema::SEARCH_INDEX_DIR)
}

// ==================== 调用索引磁盘缓存(v36 C3)====================
//
// hybrid 自 v36 起为默认引擎,若每次搜索都重建知识图谱(实测约 1.2s)
// 会让默认体验劣化。调用索引是「源码变化才变」的派生数据,可用指纹
// 判失效后落盘复用。缓存语义刻意与「文档时代的图」对齐:缓存中保存的
// 是上次文档生成时的调用图——未提交的源码改动不触发重建(文档未更新
// 时展示与文档同代的补全,一致性优于每次现扫的现状)。

/// 调用索引缓存指纹:git 仓库取 HEAD 提交;非 git 仓库取生成状态文件
/// 的 (字节数, 修改时间)——generate/update 每次落盘状态文件,其变化即
/// 代表文档生成状态变化,可保守判定调用图是否需要重建。
fn call_index_fingerprint(config: &config::schema::WikiConfig) -> Option<String> {
    let root = config.output_dir().parent()?;
    // git 优先:HEAD 精确代表「源码版本」
    if let Ok(repo) = git2::Repository::discover(root)
        && let Ok(head) = repo.head()
        && let Some(target) = head.target()
    {
        return Some(format!("git:{}", target));
    }
    // 非 git:生成状态文件 (len, mtime) 作为粗指纹
    let state_path = config.output_dir().join(".state").join("generation_state.json");
    let meta = std::fs::metadata(&state_path).ok()?;
    let mtime = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
    // 亚秒精度:文件系统 mtime 精度远高于秒(NTFS 100ns),
    // 同一秒内的状态重写也必须判失效(generate/update 连续落盘场景)
    Some(format!("state:{}:{}", meta.len(), mtime.as_millis()))
}

/// 加载调用索引缓存:指纹匹配且 JSON 可解析才命中,否则返回 None
///(未命中/损坏/无法计算指纹均视为无缓存,调用方走重建路径)。
fn load_call_index_cache(config: &config::schema::WikiConfig) -> Option<search::callgraph::CallIndex> {
    let fp = call_index_fingerprint(config)?;
    let state_dir = config.output_dir().join(".state");
    let fp_file = std::fs::read_to_string(state_dir.join("call_index.fingerprint")).ok()?;
    if fp_file.trim() != fp {
        return None;
    }
    let data = std::fs::read_to_string(state_dir.join("call_index.json")).ok()?;
    serde_json::from_str(&data).ok()
}

/// 写调用索引缓存(尽力而为:失败仅告警——搜索主功能不受影响,
/// 下次搜索会走重建路径)。指纹与索引内容同写,保证原子判失效。
fn save_call_index_cache(config: &config::schema::WikiConfig, index: &search::callgraph::CallIndex) {
    let Some(fp) = call_index_fingerprint(config) else {
        return;
    };
    let state_dir = config.output_dir().join(".state");
    if let Err(e) = std::fs::create_dir_all(&state_dir) {
        tracing::warn!("调用索引缓存目录创建失败: {}", e);
        return;
    }
    match serde_json::to_string(index) {
        Ok(json) => {
            if let Err(e) = std::fs::write(state_dir.join("call_index.json"), json) {
                tracing::warn!("调用索引缓存写入失败: {}", e);
                return;
            }
            if let Err(e) = std::fs::write(state_dir.join("call_index.fingerprint"), fp) {
                tracing::warn!("调用索引指纹写入失败: {}", e);
            }
        }
        Err(e) => tracing::warn!("调用索引序列化失败: {}", e),
    }
}

// ==================== 语义降级标记(v32 10.1)====================
//
// 语义索引是「附加能力」:embed 初始化/运行期失败时生成流程降级保留旧索引
// (见 build_search_index)。降级只写日志会让用户误以为语义搜索可用——
// 本标记把降级事实持久化(.search/semantic_degraded,内容=原因),
// search/status 命令读取后输出显式提示行。每次生成重新求值:
// 成功→清标记,降级→写标记;命令只读不写。

/// 降级标记文件路径(.search/semantic_degraded,内容=降级原因)
fn semantic_degraded_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
    search_index_dir(config).join("semantic_degraded")
}

/// 写入降级标记(尽力而为——标记失败不影响主流程,下次生成会重试)
fn mark_semantic_degraded(config: &config::schema::WikiConfig, reason: &anyhow::Error) {
    let marker = semantic_degraded_marker(config);
    if let Err(e) = std::fs::write(&marker, reason.to_string()) {
        tracing::warn!("写语义降级标记失败 {}: {}", marker.display(), e);
    }
}

/// 清除降级标记(语义索引本次成功构建/更新后调用)
fn clear_semantic_degraded(config: &config::schema::WikiConfig) {
    let _ = std::fs::remove_file(semantic_degraded_marker(config));
}

/// 读取降级原因(无标记返回 None;命令层输出「语义索引:正常/已降级」)
pub fn semantic_degraded_reason(config: &config::schema::WikiConfig) -> Option<String> {
    let marker = semantic_degraded_marker(config);
    std::fs::read_to_string(&marker).ok()
}

/// embedding 模型标记文件路径(.search/embed_model.json)
///
/// 用途:embedding 模型升级(同维度)时强制全量重建语义索引。维度探测
/// (U04/D2)只覆盖「维度变化」;同维度模型(如 qwen3 → qwen3.7 同为
/// 1024 维)的向量语义空间不同,新旧向量混存会静默劣化检索结果。
/// 本标记持久化「索引构建时的模型名」,增量路径与当前配置比对,
/// 不一致即回退全量重建语义索引。
fn embed_model_marker(config: &config::schema::WikiConfig) -> std::path::PathBuf {
    search_index_dir(config).join("embed_model.json")
}

/// 读取索引构建时的 embedding 模型名
///
/// - 标记缺失(旧版本构建的索引,模型未知)→ None
/// - 标记损坏(非 JSON / 缺 model 字段)→ None
///
/// 两者都按「未知模型」保守处理:增量路径视为不匹配并回退全量重建,
/// 重建成功后写入新标记,自愈收敛(不会反复重建)。
fn read_embed_model(config: &config::schema::WikiConfig) -> Option<String> {
    let path = embed_model_marker(config);
    let text = std::fs::read_to_string(&path).ok()?;
    serde_json::from_str::<serde_json::Value>(&text)
        .ok()
        .and_then(|v| v.get("model")?.as_str().map(|s| s.to_string()))
}

/// 记录当前 embedding 模型名(只在全量重建语义索引成功后调用)
///
/// 失败仅告警:标记缺失会让下次增量再次走全量重建(保守正确,
/// 且全量重建会再次尝试写标记,幂等收敛)。
fn write_embed_model(config: &config::schema::WikiConfig) {
    let path = embed_model_marker(config);
    let content = serde_json::json!({ "model": config.embed.model }).to_string();
    if let Err(e) = crate::fs::write_file_atomic(&path, &content) {
        tracing::warn!("embedding 模型标记写入失败(下次增量将回退全量重建): {}", e);
    }
}

/// 判定索引模型与当前配置是否不匹配(不匹配需回退全量重建语义索引)
///
/// 标记缺失/损坏一律视为不匹配(旧版本构建的索引模型未知,保守重建),
/// 重建成功后写入新标记自愈收敛。纯文件比对、无网络依赖,可单测。
fn embed_model_mismatch(config: &config::schema::WikiConfig) -> bool {
    read_embed_model(config).as_deref() != Some(config.embed.model.as_str())
}

/// 实体级特征聚类接线(演进计划 T1.2b)
///
/// 在 build_graph 之后调用:embed 未启用或 EmbeddingEngine 初始化失败时
/// 降级为纯结构聚类(detect_features 的 embedder 参数传 None)。
/// 特征聚类失败只告警不中断主流程(特征是附加信息,不影响生成主链路)。
fn attach_features(graph: &mut model::KnowledgeGraph, config: &config::schema::WikiConfig) {
    let embedder: Option<std::sync::Arc<dyn analysis::feature::Embedder>> =
        match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
        Ok(e) => {
            // 显式经中间 let 触发 unsize coercion(Option 内不自动转换)
            let engine: std::sync::Arc<dyn analysis::feature::Embedder> = std::sync::Arc::new(e);
            Some(engine)
        }
        Err(e) => {
            tracing::warn!("特征聚类 Embedding 初始化失败,降级为纯结构聚类: {e}");
            None
        }
    };
    match analysis::feature::detect_features(graph, embedder.as_deref()) {
        Ok(features) => {
            graph.features = features;
            tracing::info!("特征聚类完成: {} 个特征", graph.features.len());
        }
        Err(e) => {
            tracing::warn!("特征聚类失败(不影响主流程): {e}");
        }
    }
}

/// 全量构建搜索索引
///
/// 遍历知识图谱中所有实体节点,从 FileInsight 中提取对应源码片段,
/// 批量索引到 TextEngine。如果 embed 已启用则同时构建 SemanticEngine。
fn build_search_index(
    graph: &model::KnowledgeGraph,
    file_insights: &[ingest::parser::FileInsight],
    config: &config::schema::WikiConfig,
) -> anyhow::Result<()> {
    let index_dir = search_index_dir(config);
    std::fs::create_dir_all(&index_dir)?;

    // 构建文件路径 → 源码的查找表
    let source_map = build_source_map(file_insights);

    // 收集所有需要索引的实体(U04/D2:与增量路径共用 collect_index_items,
    // 过滤规则单一来源)
    let items = collect_index_items(graph, &source_map);

    // 全量重建 TextEngine
    let text_path = index_dir.join("text_index.db");
    let _ = std::fs::remove_file(&text_path); // 清除旧索引
    let (mut text_engine, _) = search::text::TextEngine::open(&text_path)?;
    text_engine.index_batch(&items)?;

    // 如果 embed 已启用,构建语义索引
    let semantic_path = index_dir.join("semantic_index.db");
    // 票 10 时序修正:先初始化 Embedding 引擎、成功后再删旧索引——
    // 旧实现先删后初始化,key 缺失时旧索引已丢且引导误导
    //("请启用 embed"掩盖了真实原因是 key 未配置)。
    // 失败时保留旧索引(可回退旧语义结果),并在引导中区分两种失败。
    match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
        Ok(embedder) => {
            let _ = std::fs::remove_file(&semantic_path);
            let embedder = std::sync::Arc::new(embedder);
            // 运行期失败(key 缺失/网络不可达)同样降级保留旧索引,不得
            // `?` 中断主流程——与上方初始化失败的降级语义一致(v30 前
            // embed.enabled=false 时整段跳过,无此失败路径;恒启用后
            // 必须把两类失败都按"附加能力"对待)。
            // v32 10.1:降级同时写标记(search/status 显式提示),
            // 成功则清标记。
            match search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone()) {
                Ok(mut semantic_engine) => match semantic_engine.index_batch(&items) {
                    Ok(()) => {
                        tracing::info!("语义索引构建完成: {} 个实体已向量化", items.len());
                        clear_semantic_degraded(config);
                        // v33:记录构建时的 embedding 模型名(模型升级检测基准)
                        write_embed_model(config);
                    }
                    Err(e) => {
                        tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
                        let _ = std::fs::remove_file(&semantic_path);
                        mark_semantic_degraded(config, &e);
                    }
                },
                Err(e) => {
                    tracing::warn!("语义索引构建失败(保留旧索引,搜索回退纯文本): {}", e);
                    mark_semantic_degraded(config, &e);
                }
            }
        }
        Err(e) => {
            tracing::warn!("语义索引构建跳过(Embedding 引擎初始化失败,保留旧索引): {}", e);
            mark_semantic_degraded(config, &e);
        }
    }

    tracing::info!("搜索索引构建完成: {} 个实体已索引", items.len());
    Ok(())
}

/// 增量更新搜索索引
///
/// 只删除变更文件的旧实体,再重新索引变更文件中的新实体。
/// 同时处理 TextEngine 和 SemanticEngine(如已启用)。
fn update_search_index_incremental(
    graph: &model::KnowledgeGraph,
    file_insights: &[ingest::parser::FileInsight],
    config: &config::schema::WikiConfig,
    changed_files: &std::collections::HashSet<std::path::PathBuf>,
) -> anyhow::Result<()> {
    let index_dir = search_index_dir(config);
    let text_path = index_dir.join("text_index.db");

    // 索引不存在时回退到全量构建
    if !text_path.exists() {
        return build_search_index(graph, file_insights, config);
    }

    let (mut text_engine, need_reindex) = search::text::TextEngine::open(&text_path)?;

    // 分支内统计量提升到外层:函数末尾的汇总日志需要(Rust 作用域);
    // source_map/items 同样提升:语义增量段(下方)需要引用。
    // 延迟初始化(两个分支必赋值其一):避免空值占位引发
    // unused_assignments 误报,也杜绝「空 Vec 兜底」掩盖逻辑。
    let source_map = build_source_map(file_insights);
    let mut total_removed = 0;
    let indexed_count;
    let items: Vec<(model::CodeNode, String)>;

    if need_reindex {
        // v36 schema 迁移:旧版 text 索引(无 CJK tokens 列)被 open 时
        // 重建为空表,增量补 changed_files 会丢失全部旧实体——回退
        // 全量文本重索引(纯文本、无 LLM,成本低;语义索引不受影响,
        // 继续走下方增量路径)
        tracing::warn!("文本索引 schema 已升级(CJK tokens 列),重建全量文本索引");
        items = collect_index_items(graph, &source_map);
        indexed_count = items.len();
        text_engine.index_batch(&items)?;
    } else {
        // 删除变更文件的旧索引
        for file in changed_files {
            let file_str = file.to_string_lossy();
            total_removed += text_engine.remove_by_file(&file_str)?;
        }

        // 重新索引变更文件中的实体(与全量路径共用 collect_index_items,
        // 过滤规则单一来源)
        items = incremental_index_items(graph, file_insights, changed_files);
        indexed_count = items.len();
        text_engine.index_batch(&items)?;
    }

    // 增量更新语义索引(如已启用)
    let semantic_path = index_dir.join("semantic_index.db");
    // A1(v14):入口失败显式告警——此前两处 `if let Ok(...)` 静默吞掉
    // EmbeddingEngine::new(key 缺失)与 SemanticEngine::open(DB 损坏)的
    // 失败,增量语义更新在用户不知情时整段跳过(与全量路径 :702/:707 的
    // warn 语义对齐:保留旧索引可观测,不静默)。
    if semantic_path.exists() {
        match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
            Ok(embedder) => {
                let embedder = std::sync::Arc::new(embedder);
                match search::semantic::SemanticEngine::open(&semantic_path, embedder.clone(), get_global_runtime().clone()) {
                    Ok(mut semantic_engine) => {
                        // v33:embedding 模型版本化——同维度模型升级强制全量重建。
                        // 维度探测(U04/D2)只覆盖维度变化;同维度模型(维度相同)
                        // 混用旧向量会静默劣化检索。标记缺失/损坏视为未知模型
                        // (旧版构建),保守回退全量重建一次并写回新标记自愈。
                        let stored_model = read_embed_model(config);
                        let model_mismatch = embed_model_mismatch(config);
                        // 模型不匹配时无需探测维度(直接全量重建)
                        let dim_changed = if model_mismatch {
                            false
                        } else {
                            // U04/D2:embedding 维度探测——换模型(维度变化)时,增量
                            // 删除 + 只回填变更集会把既有全部向量丢掉(vecdb 维度不匹配
                            // 重建 DROP 全表,仅 warn)。探测到维度变化则回退全量重建
                            // 语义索引(clear + 全量 items),与全量路径行为一致。
                            let probe_dim = if items.is_empty() {
                                None
                            } else {
                                match get_global_runtime().block_on(embedder.embed(&items[0].1)) {
                                    Ok(v) => Some(v.len()),
                                    Err(e) => {
                                        tracing::warn!("embedding 维度探测失败,跳过维度重建检查: {}", e);
                                        None
                                    }
                                }
                            };
                            // 维度探测失败(数据库损坏/权限)显式告警并跳过重建检查,
                            // 不静默当作"维度未变"——保持行为的同时错误可见
                            match semantic_engine.table_dimension() {
                                Ok(existing_dim) => probe_dim
                                    .zip(existing_dim)
                                    .is_some_and(|(new_dim, existing)| new_dim != existing),
                                Err(e) => {
                                    tracing::warn!("读取语义索引维度失败,跳过维度重建检查: {}", e);
                                    false
                                }
                            }
                        };
                        if model_mismatch {
                            tracing::warn!(
                                "embedding 模型变化(标记 {:?} → 配置 {}),回退全量重建语义索引(新旧模型向量空间不兼容)",
                                stored_model,
                                config.embed.model
                            );
                            let all_items = collect_index_items(graph, &source_map);
                            semantic_engine.clear()?;
                            semantic_engine.index_batch(&all_items)?;
                            write_embed_model(config);
                        } else if dim_changed {
                            tracing::warn!(
                                "embedding 维度变化,回退全量重建语义索引(增量删除+回填会丢全部既有向量)"
                            );
                            let all_items = collect_index_items(graph, &source_map);
                            semantic_engine.clear()?;
                            semantic_engine.index_batch(&all_items)?;
                        } else {
                            // t01/P1-1:删除与回填错误显式传播(与同函数 text 路径一致)。
                            // 此前 `let _` 吞错:文本索引已更新而向量库停留旧态(新旧混存),
                            // 搜索返回陈旧/错位结果且无任何日志;语义索引是搜索功能的一部分,
                            // 静默失败不可接受。函数级隔离哲学不变——调用方(lib.rs Phase 5)
                            // 仍以 warn 包装,不中断主流程。
                            for file in changed_files {
                                semantic_engine.remove_by_file(&file.to_string_lossy())?;
                            }
                            semantic_engine.index_batch(&items)?;
                        }
                        // v32 10.1:增量语义更新成功(含维度重建路径)→ 清降级标记
                        clear_semantic_degraded(config);
                    }
                    Err(e) => {
                        tracing::warn!("语义索引打开失败,增量语义更新跳过(保留旧索引): {}", e);
                        // v32 10.1:降级标记(search/status 显式提示)
                        mark_semantic_degraded(config, &e);
                    }
                }
            }
            Err(e) => {
                tracing::warn!("Embedding 引擎初始化失败,增量语义更新跳过(保留旧索引): {}", e);
                // v32 10.1:降级标记
                mark_semantic_degraded(config, &e);
            }
        }
    }

    tracing::info!("搜索索引增量更新: 删除 {} 条, 新增 {} 条", total_removed, indexed_count);
    Ok(())
}

/// 收集全部可索引实体(项目/模块/文件级节点跳过),全量与增量路径共用
///
/// U04/D2 提取:增量路径的"变更文件过滤"是 collect 之后的选择,
/// 维度变化回退全量重建直接复用本函数,保证过滤规则单一来源。
fn collect_index_items(
    graph: &model::KnowledgeGraph,
    source_map: &std::collections::HashMap<String, String>,
) -> Vec<(model::CodeNode, String)> {
    graph
        .graph
        .node_indices()
        .filter_map(|idx| {
            let node = graph.graph.node_weight(idx)?;
            // 跳过项目/模块/文件级别的节点,只索引具体实体
            if matches!(
                node.kind,
                model::NodeKind::Project | model::NodeKind::Module | model::NodeKind::File
            ) {
                return None;
            }
            let source = extract_entity_source(node, source_map);
            Some((node.clone(), source))
        })
        .collect()
}

/// 构建文件路径 → 文件源码的查找表(直接使用 FileInsight.source 避免重复 I/O)
fn build_source_map(insights: &[ingest::parser::FileInsight]) -> std::collections::HashMap<String, String> {
    insights.iter()
        .map(|i| (i.path.to_string_lossy().to_string(), i.source.clone()))
        .collect()
}

/// 收集增量路径的待索引实体:全量 items 中只保留属于变更文件的实体
///
/// 与全量路径共用 collect_index_items(过滤规则单一来源),再按
/// 变更文件集过滤。路径比较前归一化分隔符(票 08):node.file_path
/// 可能是平台反斜杠路径,changed_files 来自 git diff/watch(正斜杠
/// 或相对路径),比较点必须同基准,否则增量删除/重索引在 Windows
/// 上永不命中。
fn incremental_index_items(
    graph: &model::KnowledgeGraph,
    file_insights: &[ingest::parser::FileInsight],
    changed_files: &std::collections::HashSet<std::path::PathBuf>,
) -> Vec<(model::CodeNode, String)> {
    let source_map = build_source_map(file_insights);
    collect_index_items(graph, &source_map)
        .into_iter()
        .filter(|(node, _)| {
            let Some(node_file) = node.file_path.as_deref() else {
                return false;
            };
            let node_file_norm = incremental::norm_sep(node_file);
            changed_files
                .iter()
                .any(|f| incremental::norm_sep(&f.to_string_lossy()) == node_file_norm)
        })
        .collect()
}

/// 从源码中提取实体对应的代码片段
///
/// 根据实体的 line_range 从源文件中截取对应行。
fn extract_entity_source(
    node: &model::CodeNode,
    source_map: &std::collections::HashMap<String, String>,
) -> String {
    let file_path = match &node.file_path {
        Some(p) => p,
        None => return node.signature.clone().unwrap_or_default(),
    };
    let source = match source_map.get(file_path) {
        Some(s) => s,
        None => return node.signature.clone().unwrap_or_default(),
    };
    let (start, end) = match node.line_range {
        Some(r) => r,
        None => return node.signature.clone().unwrap_or_default(),
    };
    // 截取对应行(1-based 转 0-based)
    source.lines()
        .skip(start.saturating_sub(1))
        .take(end.saturating_sub(start) + 1)
        .collect::<Vec<_>>()
        .join("\n")
}

/// 执行搜索查询(供 CLI search 子命令调用)
///
/// 加载持久化索引,根据引擎类型执行搜索,返回结果列表。
/// - Text: 仅 BM25 全文搜索
/// - Semantic: 仅向量语义搜索(需 embed.enabled)
/// - Hybrid: 两者结果经 RRF 合并
pub fn execute_search(
    config_path: Option<&Path>,
    root: &project::ProjectRoot,
    query: &str,
    top_k: usize,
    engine_type: &config::schema::SearchEngineType,
) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
    if query.trim().is_empty() {
        return Ok(Vec::new());
    }
    // v25:None 走默认配置链(项目级字段级合并覆盖用户级)
    let config = match config_path {
        Some(p) => config::load_config(p)?,
        None => config::load_default_config(root)?.1,
    };
    let index_dir = search_index_dir(&config);
    let text_path = index_dir.join("text_index.db");
    let semantic_path = index_dir.join("semantic_index.db");

    match engine_type {
        config::schema::SearchEngineType::Text => {
            if !text_path.exists() {
                anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
            }
            let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
            let results = text_engine.search(query, top_k)?;
            Ok(search::hybrid::text_results_to_hits(results))
        }
        config::schema::SearchEngineType::Semantic => {
            // v30:embed 已硬编码恒启用——语义索引缺失即引导(无嵌入
            // key 时 generate 会告警跳过语义索引构建,见 build_search_index)
            if !semantic_path.exists() {
                anyhow::bail!("语义索引不存在——未配置嵌入 key(embed.api_key_env)或索引未构建,请配置后重新运行 `code-repo-wiki generate`");
            }
            let embedder = generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone())?;
            let embedder = std::sync::Arc::new(embedder);
            let semantic_engine = search::semantic::SemanticEngine::open(&semantic_path, embedder, get_global_runtime().clone())?;
            let results = semantic_engine.search(query, top_k)?;
            Ok(search::hybrid::semantic_results_to_hits(results))
        }
        config::schema::SearchEngineType::Hybrid => {
            // 与 Text/Semantic 分支一致:text 索引是混合检索的必需底座
            //(RRF 至少一路有效),缺失时明确报错而非打开空库。
            if !text_path.exists() {
                anyhow::bail!("搜索索引不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 构建索引");
            }
            let (text_engine, _) = search::text::TextEngine::open(&text_path)?;
            // hybrid 语义一路:语义引擎构建失败(embedding 配置缺失/key
            // 无效/数据库损坏)显式告警并降级为纯 text——搜索结果少一路
            // 召回,但错误可见而非静默(v5 审计:全 .ok() 链把失败全吞掉,
            // 用户配置了 embed 却永远收不到语义结果且无任何提示)
            let semantic_engine: Option<Box<dyn search::semantic::SemanticSearch>> =
                if semantic_path.exists() {
                    match generate::embed::EmbeddingEngine::new(&config.embed, get_global_runtime().handle().clone()) {
                        Ok(e) => match search::semantic::SemanticEngine::open(
                            &semantic_path,
                            Arc::new(e),
                            get_global_runtime().clone(),
                        ) {
                            Ok(engine) => Some(Box::new(engine) as Box<dyn search::semantic::SemanticSearch>),
                            Err(e) => {
                                tracing::warn!("语义索引打开失败,hybrid 降级为纯 text: {}", e);
                                None
                            }
                        },
                        Err(e) => {
                            tracing::warn!("embedding 引擎初始化失败,hybrid 降级为纯 text: {}", e);
                            None
                        }
                    }
                } else { None };
            let mut agent = search::agent::SearchAgent::new(text_engine, semantic_engine, config::schema::SEARCH_RRF_K);
            // 调用链补全:优先加载磁盘缓存(v36:hybrid 为默认引擎,
            // 缓存按源码指纹失效,命中时跳过整次图谱重建;重建成本仅
            // 在指纹变化后付出一次)。缓存与重建失败均静默降级为无补全
            //(索引缺失等,搜索主功能不受影响)。
            let index = match load_call_index_cache(&config) {
                Some(i) => i,
                None => {
                    if let Ok(scan) = ingest::scan_and_parse_at(root)
                        && let Ok(graph) = analysis::build_graph(&scan.insights)
                    {
                        let index = search::callgraph::CallGraph::new(&graph).build_call_index();
                        save_call_index_cache(&config, &index);
                        index
                    } else {
                        HashMap::new()
                    }
                }
            };
            agent = agent.with_call_index(index);
            // v36 起 hybrid = 双引擎召回 + RRF 融合 + 调用链补全
            // (v36 用户拍板:不使用 rerank 精排——召回质量已足够,
            // 精排增加延迟与外部依赖,收益不成比例)
            Ok(agent.search(query, top_k, true))
        }
    }
}

/// 执行 AST 精确符号查找(供 CLI `ast-search` 子命令调用)
///
/// 扫描配置范围内全部源文件,对每个文件用 tree-sitter 解析 AST,
/// 定位与 `symbol` 同名的顶层定义节点(函数/结构体/trait/类等)。
/// 与索引搜索(text/semantic/hybrid,模糊匹配)互补:AST 查找返回
/// **精确的定义位置**(文件+行号+签名),不依赖搜索索引。
///
/// `language` 为源语言(rust/python/go/...),传入 None 时由文件扩展名自动推断。
pub fn execute_ast_search(
    config_path: Option<&Path>,
    root: &project::ProjectRoot,
    symbol: &str,
    language: Option<&str>,
) -> anyhow::Result<Vec<search::hybrid::SearchHit>> {
    if symbol.trim().is_empty() {
        return Ok(Vec::new());
    }
    let _config = match config_path {
        // 配置在此 fail-fast 校验(无效配置提前报错);AST 检索本身不依赖配置
        Some(p) => config::load_config(p)?,
        None => config::load_default_config(root)?.1,
    };
    let insights = ingest::scan_and_parse_at(root)?.insights;

    let mut hits = Vec::new();
    for insight in &insights {
        // 语言:显式指定优先;否则按文件扩展名推断(与 parser 注册一致)
        let lang = match language {
            Some(l) => l.to_string(),
            None => match insight.path.extension().and_then(|e| e.to_str()) {
                Some("rs") => "rust".to_string(),
                Some("py") => "python".to_string(),
                Some("js") => "javascript".to_string(),
                Some("ts") => "typescript".to_string(),
                Some("go") => "go".to_string(),
                Some("cs") => "csharp".to_string(),
                _ => continue,
            },
        };
        // 直接用 AstQuery 解析查找(不经过 SearchAgent,搜索上下文不依赖索引)
        let mut q = match search::ast::AstQuery::new(&lang) {
            Ok(q) => q,
            Err(_) => continue,
        };
        let Ok(Some(m)) = q.find_definition(&insight.source, symbol) else {
            continue;
        };
        // 捕获节点文本作为签名(如整行函数定义);定位到文件+行号
        let signature = m
            .captures
            .get("name")
            .cloned()
            .unwrap_or_else(|| symbol.to_string());
        // 模块路径从文件父目录派生(与 chunk_by_file 同规则:Normal 组件 "::" 连接)
        let module_path: Vec<String> = insight
            .path
            .parent()
            .map(|p| {
                p.components()
                    .filter(|c| matches!(c, std::path::Component::Normal(_)))
                    .map(|c| c.as_os_str().to_string_lossy().to_string())
                    .collect()
            })
            .unwrap_or_default();
        hits.push(search::hybrid::SearchHit {
            node: model::CodeNode {
                id: model::NodeId::new(0),
                kind: model::NodeKind::Function,
                name: symbol.to_string(),
                file_path: Some(insight.path.to_string_lossy().to_string()),
                line_range: Some((m.start_line, m.end_line)),
                doc_comment: None,
                signature: Some(signature), visibility: None,
                module_path,
            },
            score: 100.0,
            source: "ast".into(),
            callers: vec![],
            callees: vec![],
        });
    }
    Ok(hits)
}

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

    /// v33 生产审计 ②:embedding 模型标记写读往返 + 不匹配判定
    ///
    /// 模型版本化判定为纯文件比对(无网络),在此做单元级覆盖;
    /// 全链路(增量触发重建)依赖真实 embed key,留待真实环境验证。
    #[test]
    fn test_embed_model_marker_roundtrip_and_mismatch() {
        let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_embed_marker_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join(".search")).unwrap();

        let mut config = config::schema::WikiConfig {
            output_dir: Some(dir.clone()),
            embed: config::schema::EmbedSection {
                model: "model-a".into(),
                ..Default::default()
            },
            ..Default::default()
        };

        // 无标记(旧版构建的索引)→ 视为不匹配(保守触发重建)
        assert!(embed_model_mismatch(&config), "标记缺失应视为模型不匹配");

        // 写入标记后匹配
        write_embed_model(&config);
        assert!(!embed_model_mismatch(&config), "标记与配置一致应匹配");
        assert_eq!(read_embed_model(&config).as_deref(), Some("model-a"));

        // 模型升级(同维度)→ 不匹配
        config.embed.model = "model-b".into();
        assert!(embed_model_mismatch(&config), "同维度模型升级应判定不匹配");

        // 重写标记自愈 → 匹配
        write_embed_model(&config);
        assert!(!embed_model_mismatch(&config));
        assert_eq!(read_embed_model(&config).as_deref(), Some("model-b"));

        // 标记损坏 → 视为未知模型(不匹配)
        std::fs::write(dir.join(".search").join("embed_model.json"), "{broken").unwrap();
        assert!(embed_model_mismatch(&config), "损坏标记应视为不匹配");

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

    /// v32 8.1:分段计时序列化往返与写盘/读取(缺省字段补零、损坏文件不 panic)
    #[test]
    fn test_generation_timings_roundtrip() {
        let timings = GenerationTimings {
            scan_parse_ms: 1,
            graph_ms: 2,
            incremental_ms: 3,
            chunk_ms: 4,
            card_ms: 5,
            wiki_ms: 6,
            index_guide_ms: 7,
            render_ms: 8,
            index_ms: 9,
            state_ms: 10,
            total_ms: 55,
        };
        let text = serde_json::to_string_pretty(&timings).unwrap();
        let back: GenerationTimings = serde_json::from_str(&text).unwrap();
        assert_eq!(back.scan_parse_ms, 1);
        assert_eq!(back.total_ms, 55);
        // 损坏文件 → 解析失败(调用方按 None 处理,不 panic)
        assert!(serde_json::from_str::<GenerationTimings>("{broken").is_err());
        // 缺字段 → serde(default) 补零
        let partial: GenerationTimings =
            serde_json::from_str(r#"{"scan_parse_ms": 42}"#).unwrap();
        assert_eq!(partial.scan_parse_ms, 42);
        assert_eq!(partial.total_ms, 0);
    }

    /// 产物集合 diff 清理(票 10):旧状态记录过、但本次渲染集合之外的
    /// 产物路径被删除(全语言目录),本次渲染集合内的路径(含受保护文档)
    /// 一律保留。
    #[test]
    fn test_cleanup_stale_outputs_removes_unrendered_across_languages() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_stale_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);

        // 旧状态记录两个产物:src.md(双语言)与 lib.md(双语言)
        let mut state = incremental::state::GenerationState {
            last_commit_hash: None,
            file_fingerprints: std::collections::HashMap::new(),
            doc_fingerprints: std::collections::HashMap::new(),
            doc_modules: std::collections::HashMap::new(),
            protected_docs: vec![],
            generated_at: String::new(),
            tool_version: None,
            failed_modules: vec![],
        };
        for lang in ["zh", "en"] {
            let stale = dir.join("wiki").join(lang).join("src.md");
            let keep = dir.join("wiki").join(lang).join("lib.md");
            std::fs::create_dir_all(stale.parent().unwrap()).unwrap();
            std::fs::create_dir_all(keep.parent().unwrap()).unwrap();
            std::fs::write(&stale, "旧页面").unwrap();
            std::fs::write(&keep, "保留页面").unwrap();
            state
                .doc_fingerprints
                .insert(stale.to_string_lossy().to_string(), "fp".into());
            state
                .doc_fingerprints
                .insert(keep.to_string_lossy().to_string(), "fp".into());
        }

        // 本次渲染集合只含 lib.md(src.md 对应模块已消失,不在渲染集)
        let rendered: Vec<std::path::PathBuf> = ["zh", "en"]
            .iter()
            .map(|lang| dir.join("wiki").join(lang).join("lib.md"))
            .collect();

        // preserved 为空:src.md 的模块 src::foo 不在保留集 → 按原语义清理
        cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());

        for lang in ["zh", "en"] {
            assert!(
                !dir.join("wiki").join(lang).join("src.md").exists(),
                "未渲染的旧产物应被清理({lang})"
            );
            assert!(
                dir.join("wiki").join(lang).join("lib.md").exists(),
                "本次渲染集合内的产物应保留({lang})"
            );
        }

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

    /// 受保护文档在渲染集合内(rendered_paths 含受保护路径),diff 后
    /// 不会被误删——人工编辑内容由保护语义而非清理语义保障。
    #[test]
    fn test_cleanup_stale_outputs_keeps_rendered_protected() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_stale_protected_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);

        let mut state = incremental::state::GenerationState {
            last_commit_hash: None,
            file_fingerprints: std::collections::HashMap::new(),
            doc_fingerprints: std::collections::HashMap::new(),
            doc_modules: std::collections::HashMap::new(),
            protected_docs: vec![],
            generated_at: String::new(),
            tool_version: None,
            failed_modules: vec![],
        };
        // 受保护页面被人工编辑过(指纹不匹配)——doc_fingerprints 仍记录其路径
        let manual = dir.join("wiki").join("zh").join("manual.md");
        std::fs::create_dir_all(manual.parent().unwrap()).unwrap();
        std::fs::write(&manual, "人工编辑内容").unwrap();
        state
            .doc_fingerprints
            .insert(manual.to_string_lossy().to_string(), "旧指纹".into());
        state
            .doc_modules
            .insert(manual.to_string_lossy().to_string(), "manual".into());

        // 本次渲染集合包含该路径(受保护文档属于生成集)
        let rendered = vec![manual.clone()];
        cleanup_stale_outputs(Some(&state), &rendered, &std::collections::HashSet::new());

        assert!(
            manual.exists(),
            "渲染集合内的人工编辑文档不应被清理"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// v17 F 组(t09 实测修复):增量模式下未受影响模块的旧页面必须保留
    /// ——模块仍在当前扫描(preserved 集合)中,即使本次未重新生成,
    /// 清理也须跳过(误删会制造断链)
    #[test]
    fn test_cleanup_stale_outputs_preserves_modules_still_in_scan() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_stale_preserve_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);

        let mut state = incremental::state::GenerationState {
            last_commit_hash: None,
            file_fingerprints: std::collections::HashMap::new(),
            doc_fingerprints: std::collections::HashMap::new(),
            doc_modules: std::collections::HashMap::new(),
            protected_docs: vec![],
            generated_at: String::new(),
            tool_version: None,
            failed_modules: vec![],
        };
        // 旧状态:src::fs 模块的页面(模拟增量前生成的产物)
        let fs_page = dir.join("wiki").join("zh").join("src_fs.md");
        std::fs::create_dir_all(fs_page.parent().unwrap()).unwrap();
        std::fs::write(&fs_page, "旧内容").unwrap();
        state
            .doc_fingerprints
            .insert(fs_page.to_string_lossy().to_string(), "fp".into());
        state
            .doc_modules
            .insert(fs_page.to_string_lossy().to_string(), "src::fs".into());
        // 旧状态:src::deleted 模块的页面(模拟源文件已删除的模块)
        let gone_page = dir.join("wiki").join("zh").join("src_deleted.md");
        std::fs::write(&gone_page, "旧内容").unwrap();
        state
            .doc_fingerprints
            .insert(gone_page.to_string_lossy().to_string(), "fp".into());
        state
            .doc_modules
            .insert(gone_page.to_string_lossy().to_string(), "src::deleted".into());

        // 本次渲染集不含任何上述页面(增量只生成其他模块);
        // 保留集含 src::fs(模块仍在扫描)但不含 src::deleted(已删除)
        let preserved: std::collections::HashSet<String> =
            ["src::fs".to_string()].into_iter().collect();
        cleanup_stale_outputs(Some(&state), &[], &preserved);

        assert!(fs_page.exists(), "仍在扫描的模块页面应保留");
        assert!(!gone_page.exists(), "已删除模块的页面应清理");

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

    /// 无旧状态(首次生成)时清理为空操作
    #[test]
    fn test_cleanup_stale_outputs_noop_without_state() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_stale_noop_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        cleanup_stale_outputs(None, &[], &std::collections::HashSet::new());
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// A2:force=true 清空保护集(含旧 protected_docs 与人工修改检测),
    /// force=false 保留保护语义 —— 与 run_pipeline 的 --force 行为一致
    #[test]
    fn test_load_protection_force_clears_protection() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_force_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);

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

        // 构造旧 state:一个"人工修改过"的文档(磁盘内容与指纹不匹配)
        let state_dir = dir.join(".state");
        std::fs::create_dir_all(&state_dir).unwrap();
        let doc_path = dir.join("wiki").join("zh").join("src.md");
        std::fs::create_dir_all(doc_path.parent().unwrap()).unwrap();
        std::fs::write(&doc_path, "人工修改后的内容").unwrap();
        let mut state = incremental::state::GenerationState {
            last_commit_hash: None,
            file_fingerprints: std::collections::HashMap::new(),
            doc_fingerprints: std::collections::HashMap::new(),
            doc_modules: std::collections::HashMap::new(),
            protected_docs: vec![],
            generated_at: String::new(),
            tool_version: None,
            failed_modules: vec![],
        };
        state.doc_fingerprints.insert(
            doc_path.to_string_lossy().to_string(),
            "与磁盘内容不同的指纹".into(),
        );
        state.doc_modules.insert(
            doc_path.to_string_lossy().to_string(),
            "src".into(),
        );
        state.save(&state_dir).unwrap();

        // force=false:保护集包含检测出的人工修改(下次生成不覆盖)
        let (protected, _) = load_protection(&config, false).unwrap();
        assert!(
            protected.contains(&doc_path.to_string_lossy().to_string()),
            "force=false 应保护人工修改的文档"
        );

        // force=true:保护集清空(render_all 将覆盖所有文档)
        let (protected, _) = load_protection(&config, true).unwrap();
        assert!(protected.is_empty(), "force=true 应清空保护集");

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

    /// 票 02:state.json 存在但损坏(非 JSON)时 load_protection 必须 fail-loud,
    /// 不得静默返回空保护集(空保护会让人工修改保护在后续 update 中失效)。
    /// 与 sync_from_git 对损坏状态的拒绝行为对偶(tests/test_git_sync.rs:109-121)。
    #[test]
    fn test_load_protection_corrupt_state_fails_loud() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_corrupt_state_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);

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

        // 写入损坏的状态文件(半截 JSON)
        let state_dir = dir.join(".state");
        std::fs::create_dir_all(&state_dir).unwrap();
        std::fs::write(state_dir.join("generation_state.json"), "{ 半截").unwrap();

        let err = load_protection(&config, false).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("状态文件损坏"), "应明确报告损坏, 实际: {msg}");

        // force=true 不受影响(清空保护是显式操作,不读状态)
        assert!(load_protection(&config, true).unwrap().0.is_empty());

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

    /// 票 02:状态文件不存在(首次运行)是合法场景,返回空保护不报错
    #[test]
    fn test_load_protection_missing_state_is_ok() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_missing_state_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);

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

        let (protected, state) = load_protection(&config, false).unwrap();
        assert!(protected.is_empty());
        assert!(state.is_none());

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

    // ==================== 调用索引磁盘缓存(v36 C3)====================

    /// 非 git 且无生成状态文件时指纹为 None(保守:不缓存)
    #[test]
    fn test_call_index_fingerprint_none_without_state() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_fp_none_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
        assert!(call_index_fingerprint(&config).is_none());

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

    /// 生成状态文件存在时指纹稳定(非 git 场景),内容变化后指纹变化
    #[test]
    fn test_call_index_fingerprint_state_stable() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_fp_state_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join(".state")).unwrap();
        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();

        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
        let fp1 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
        let fp2 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
        assert_eq!(fp1, fp2, "指纹必须稳定(同状态两次调用相同)");

        // 状态文件重写(generate/update 落盘)→ mtime 变化 → 指纹变化
        std::thread::sleep(std::time::Duration::from_millis(20));
        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
        let fp3 = call_index_fingerprint(&config).expect("有状态文件应有指纹");
        assert_ne!(fp1, fp3, "状态文件重写后指纹必须变化");

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

    /// 缓存往返:保存后可加载且内容一致;指纹不匹配时视为未命中
    #[test]
    fn test_call_index_cache_round_trip_and_invalidation() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_call_cache_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join(".state")).unwrap();
        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();

        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
        let mut index = std::collections::HashMap::new();
        index.insert("fn_a".to_string(), (vec!["fn_b".to_string()], vec!["fn_c".to_string()]));

        // 保存前加载=未命中
        assert!(load_call_index_cache(&config).is_none());

        save_call_index_cache(&config, &index);
        let loaded = load_call_index_cache(&config).expect("保存后应命中");
        assert_eq!(loaded, index, "缓存往返内容必须一致");

        // 指纹失效(状态文件重写)→ 未命中
        std::thread::sleep(std::time::Duration::from_millis(20));
        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();
        assert!(load_call_index_cache(&config).is_none(), "指纹变化后必须失效");

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

    /// 损坏的缓存 JSON 视为未命中(走重建路径,不 panic)
    #[test]
    fn test_call_index_cache_corrupt_is_miss() {
        let dir = std::env::temp_dir()
            .join(format!("code_repo_wiki_test_call_cache_corrupt_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join(".state")).unwrap();
        std::fs::write(dir.join(".state/generation_state.json"), "{}").unwrap();

        let config = crate::config::schema::WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
        // 指纹匹配但 JSON 损坏
        let fp = call_index_fingerprint(&config).unwrap();
        std::fs::write(dir.join(".state/call_index.fingerprint"), &fp).unwrap();
        std::fs::write(dir.join(".state/call_index.json"), "{ 半截").unwrap();

        assert!(load_call_index_cache(&config).is_none(), "损坏缓存必须视为未命中");

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