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
use std::path::{Path, PathBuf};
use anyhow::Context;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "code-repo-wiki", about = "代码仓库 Wiki 自动生成系统", version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// 全量生成 Wiki 文档
Generate {
/// 配置文件路径(默认缺省链:项目级 config.toml → 用户级 config.toml → 创建用户级)
#[arg(short, long)]
config: Option<PathBuf>,
/// 输出目录(覆盖配置文件中的 output.dir)
#[arg(short, long)]
output: Option<PathBuf>,
/// 清空人工修改保护集,强制覆盖所有文档
#[arg(long)]
force: bool,
/// 以 JSON 行输出流水线进度(供插件解析,如 {"stage":"scanning","progress":10})
#[arg(long)]
progress_json: bool,
/// 项目根目录(扫描根/git 定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 增量更新 Wiki 文档
Update {
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 输出目录(覆盖配置文件中的 output.dir)
#[arg(short, long)]
output: Option<PathBuf>,
/// 清空人工修改保护集,强制覆盖所有文档(与 generate --force 语义一致)
#[arg(long)]
force: bool,
/// 以 JSON 行输出流水线进度(供插件解析,如 {"stage":"scanning","progress":10})
#[arg(long)]
progress_json: bool,
/// 只分析并预览将更新的页面清单,不执行生成(无副作用)
#[arg(long)]
dry_run: bool,
/// 项目根目录(扫描根/git 定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 同步产物目录内容到指纹库(Git 内容合入,不触发 LLM 生成)
Sync {
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(产物目录定位基准,默认当前目录;U02 root 补齐族)
#[arg(long)]
root: Option<PathBuf>,
},
/// 查看当前 Wiki 状态
Status {
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(产物目录定位基准,默认当前目录;U02 root 补齐族)
#[arg(long)]
root: Option<PathBuf>,
},
/// 检查 Wiki 产物健康(孤儿页/断链/过时),供 CI 使用;有问题时退出码非 0
Lint {
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(产物目录定位基准,默认当前目录;U02 root 补齐族)
#[arg(long)]
root: Option<PathBuf>,
},
/// 环境诊断(v17 t08,v21 文案对齐):配置可解析/产物目录可写/输出
/// 目录状态/LLM Key/网络/版本漂移 六查,逐项输出 ✓/✗;全过退出码 0,
/// 任一失败退出码 1
Doctor {
/// 配置文件路径(缺省走默认配置链)
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(路径定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 交互式配置 LLM API key(写入用户级 config.toml,不随 Git 共享)
///
/// 安全底线(用户拍板):明文 key 只写用户级配置;--env 改写入建议的
/// 环境变量名引用(openai→DEEPSEEK_API_KEY、anthropic→ANTHROPIC_API_KEY),
/// key 本体由 shell 环境提供。非交互终端(管道/CI/Agent)打印引导并退出 0。
Key {
/// 改用环境变量方式:写 api_key_env 引用而非明文
#[arg(long)]
env: bool,
/// 配置文件路径(仅用于读取 provider 判定;写入目标始终是用户级配置)
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(路径定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 追加一条知识沉淀记录到 _log.md(Karpathy log 模式,人工可读可 grep)
Note {
/// 记录文本
text: String,
/// 配置文件路径(取主语言写日志)
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(产物目录定位基准,默认当前目录;U02 root 补齐族)
#[arg(long)]
root: Option<PathBuf>,
},
/// 导出 Wiki 为 HTML
Export {
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 输出目录(覆盖配置文件中的 output.dir,仅 skip_generate=false 时生效)
#[arg(short, long)]
output: Option<PathBuf>,
/// 跳过生成,直接从导出快照导出(需先运行过 generate/update 落盘快照)
#[arg(long)]
skip_generate: bool,
/// 项目根目录(扫描根/git 定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 安装 code-repo-wiki 集成(v33 合并版:OpenCode 插件 + 多 Agent MCP + AGENTS.md + git hooks)
///
/// 默认执行:① 确保用户级默认配置(config.toml)存在,缺失时自动创建
/// (v25 起 init 并入 install,配置链=项目级 config.toml 覆盖用户级);
/// ② 注册 OpenCode 插件(用户级 ~/.config/opencode/plugins/code-repo-wiki.ts);
/// ③ 注册 OpenCode MCP(用户级全局 opencode.json 的 mcp 块);
/// ④ 注入 AGENTS.md wiki 引用块;⑤ 安装 git post-commit/post-merge hooks。
/// --claude 额外注册 Claude Code MCP(用户级 ~/.claude.json 顶层 mcpServers,
/// User scope——v39 起不再写项目根 .mcp.json)并同步注入 CLAUDE.md
/// (v36 起 --also-claude 并入——Claude Code 不读 AGENTS.md,注册 MCP
/// 时必然需要文档指引,两个开关分离无意义);--codex 额外注册 Codex
/// CLI MCP(用户级 ~/.codex/config.toml)。
/// 全部幂等;已存在的非 code-repo-wiki 内容(用户自定义 hook/其他 MCP server)保留。
Install {
/// 额外注册 Claude Code MCP(用户级 ~/.claude.json)并同步注入 CLAUDE.md
#[arg(long)]
claude: bool,
/// 额外注册 Codex CLI MCP(用户级 ~/.codex/config.toml,[mcp_servers.code-repo-wiki])
#[arg(long)]
codex: bool,
/// 项目根目录:插件/hook 安装基准,默认当前目录
#[arg(long)]
root: Option<PathBuf>,
},
/// 监听文件变更并自动增量更新 Wiki
Watch {
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(扫描根/监听根基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 搜索代码实体
Search {
/// 搜索关键词
#[arg(short, long)]
query: String,
/// 返回结果数量(未传时取配置 search.default_top_k)
#[arg(short = 'k', long)]
top_k: Option<usize>,
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 以 JSON 格式输出
#[arg(long)]
json: bool,
/// 搜索引擎选择: text / semantic / hybrid(默认 hybrid;hybrid 无嵌入 key 时自动降级纯 text)
#[arg(short, long)]
engine: Option<String>,
/// 项目根目录(扫描根/git 定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// AST 精确符号查找:扫描源文件定位符号定义(文件+行号+签名,不依赖搜索索引)
AstSearch {
/// 要查找的符号名(函数/结构体/trait/类等)
symbol: String,
/// 源语言(rust/python/go/...);省略时按文件扩展名自动推断
#[arg(short, long)]
language: Option<String>,
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 以 JSON 格式输出
#[arg(long)]
json: bool,
/// 项目根目录(扫描根/git 定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 知识卡片操作(Qoder /knowledge 对等)
Card {
#[command(subcommand)]
action: CardAction,
/// 项目根目录(扫描根/git 定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 卸载 code-repo-wiki 集成(v33 合并版:OpenCode MCP + 插件 + AGENTS.md + hooks
/// + Claude/Codex MCP 条目;--force 确认)
///
/// 清理 install 写入的全部集成痕迹(幂等,缺省即跳过);保留用户级
/// config.toml 与产物数据 .code-repo-wiki/。原 install-wiki/uninstall-wiki/
/// install-to-opencode/uninstall-from-opencode 四命令合并于此。
Uninstall {
/// 跳过确认(卸载将移除集成配置)
#[arg(long)]
force: bool,
/// 项目根目录(插件/hook/AGENTS.md 移除基准,默认当前目录;U02 root 补齐族)
#[arg(long)]
root: Option<PathBuf>,
},
/// 启动 MCP (Model Context Protocol) stdio server(供 Claude Code/Cline 等客户端连接)
Mcp {
/// 配置文件路径
#[arg(short, long)]
config: Option<PathBuf>,
/// 项目根目录(扫描根/git 定位基准,默认当前目录)
#[arg(long)]
root: Option<PathBuf>,
},
/// 评测基准:对目标仓库运行五维自动评测(Coverage/Doc Info/lint/Update Recall/Time)
///
/// 注意:Update Recall 维度会回放 git commit(reset --hard 工作区),
/// 评测前工作区必须干净(有未提交改动会被拒绝——安全闸)。
Bench {
/// 目标仓库根目录(必填;git 回放/扫描基准)
#[arg(long)]
root: PathBuf,
/// 仓库名(报告标识,缺省取 root 目录名)
#[arg(long)]
repo_name: Option<String>,
/// 配置文件路径(缺省 root/config.toml,见 load_default_config)
#[arg(long)]
config: Option<PathBuf>,
/// 以 JSON 格式输出报告
#[arg(long)]
json: bool,
/// 追加 TQS LLM 裁判打分维度(需配置 LLM API key;快照缺失或
/// LLM 不可用时该维度跳过)
#[arg(long)]
judge: bool,
/// v32(6.4 FR-101):RepoDocBench 对齐五维报告——强制 LLM 裁判
/// (与 --judge 正交,隐含 judge=true)并输出五维聚合摘要
/// (Coverage / Doc Information / Completeness@K / TQS /
/// Update Recall),各维缺失时降级跳过并显式标注(不得静默)。
/// 与 --rubrics-only 互斥(五维含 Update Recall 回放)。
#[arg(long, conflicts_with = "rubrics_only")]
repodoc: bool,
/// 只跑裁判层(Coverage/Doc Info/lint + TQS/Rubric),跳过
/// Update Recall 的 git commit 回放——大仓库评测时回放成本
/// 不可接受,用此模式单独完成裁判打分。
/// 与 --judge 正交:--rubrics-only --judge = 真实 LLM 裁判的
/// 快速评测(v28 t09 验证轮的标准形态)
#[arg(long)]
rubrics_only: bool,
},
/// 清单批量跑分(v21 E 组):对清单中每个仓库执行 Coverage/
/// Doc Info/lint/Time 四快维度,输出仓库×维度矩阵。
///
/// 清单格式:每行一个仓库(`#` 注释/空行跳过);本地路径直接使用,
/// `https://` / `git@` 开头视为远程 URL(clone 到 --work-dir)。
/// 每仓库产物输出到 `--work-dir/<仓库名>-out/`(不污染原仓库)。
/// Update Recall 回放与 LLM 裁判在本模式跳过(深评请用单仓库 bench)。
BenchManifest {
/// 清单文件路径
#[arg(long)]
manifest: PathBuf,
/// 模板配置文件路径(scope/llm/provider 等;缺省走默认配置链)
#[arg(long)]
config: Option<PathBuf>,
/// 以 JSON 格式输出矩阵
#[arg(long)]
json: bool,
/// 远程仓库 clone 落地与产物目录的父目录(缺省系统临时目录)
#[arg(long)]
work_dir: Option<PathBuf>,
},
}
/// 知识卡片操作子命令(业务动作定义在 lib 的 generate::card::CardAction)
#[derive(Subcommand)]
enum CardAction {
/// 为单个模块生成卡片(重新生成)
Generate {
/// 模块名(如 src::config)
module: String,
/// 配置文件路径
#[arg(long)]
config: Option<PathBuf>,
},
/// 按指令修改已有卡片
Modify {
/// 模块名(如 src::config)
module: String,
/// 修改指令
#[arg(long)]
instruction: String,
/// 参考文件路径(可重复传 --reference,可选)
#[arg(long)]
reference: Vec<PathBuf>,
/// 配置文件路径
#[arg(long)]
config: Option<PathBuf>,
},
/// 在已有卡片上追加内容
Supplement {
/// 模块名(如 src::config)
module: String,
/// 补充指令
#[arg(long)]
instruction: String,
/// 参考文件路径(可重复传 --reference,可选)
#[arg(long)]
reference: Vec<PathBuf>,
/// 配置文件路径
#[arg(long)]
config: Option<PathBuf>,
},
/// 忽略现有内容全量重写
Rewrite {
/// 模块名(如 src::config)
module: String,
/// 重写指令
#[arg(long)]
instruction: String,
/// 参考文件路径(可重复传 --reference,可选)
#[arg(long)]
reference: Vec<PathBuf>,
/// 配置文件路径
#[arg(long)]
config: Option<PathBuf>,
},
}
/// 解析 --root 参数(缺省当前目录)
///
/// ProjectRoot 是扫描根/git 定位/watch 根的注入载体(票 15);
/// output.dir 等产物路径仍按配置原样解析(相对 cwd),--root 只管
/// "代码从哪扫"而非"产物写哪"。
fn resolve_root(root: Option<&Path>) -> anyhow::Result<code_repo_wiki::project::ProjectRoot> {
match root {
// N7 修复:--root 指定的目录不存在时显式报错——此前静默通过,
// 扫描产出空集,流水线报"未找到任何源文件"(方向误导)或产物
// 静默为空。
Some(p) if !p.is_dir() => {
anyhow::bail!("--root 指定的目录不存在: {}", p.display())
}
Some(p) => Ok(code_repo_wiki::project::ProjectRoot::new(p.to_path_buf())),
None => code_repo_wiki::project::ProjectRoot::from_cwd(),
}
}
/// 进度阶段英文标识 → 中文展示名(v44:文本模式进度行)。
/// 阶段与 lib.rs run_pipeline_with_progress 的 on_progress 事件一一对应。
fn stage_zh(stage: &str) -> &str {
match stage {
"scanning" => "扫描源码",
"analyzing" => "构建知识图谱",
"chunking" => "切分文档块",
"cards" => "生成知识卡片",
"wiki" => "生成 Wiki 页",
"output" => "渲染与写盘",
"index" => "更新搜索索引",
"done" => "完成",
other => other,
}
}
fn main() -> anyhow::Result<()> {
// t03 契约(v21 实证发现):tracing_subscriber::fmt() 默认 writer 是
// stdout——所有日志会混入业务 stdout,破坏外部 AI Coding Agent 的
// stdout 事实源(update 一次几十行 info 日志)。显式切到 stderr:
// stdout 只承载业务输出(println),日志(info/warn/error)全部走
// stderr,两者可独立解析。
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
match cli.command {
Commands::Generate { config, output, force, progress_json, root } => {
let root = resolve_root(root.as_deref())?;
// v44:文本模式也走进度事件流(run_pipeline_with_progress)——
// 阶段行输出到 stderr(tracing 日志流,不污染 stdout 业务输出,
// 对齐 clig.dev「messaging to stderr」约定;非 TTY/CI 下同样是
// 普通文本行,无动画);完成摘要走 stdout(状态变更告知,AI
// 事实源可解析)。--progress-json 保持原样(插件流式解析)。
let started = std::time::Instant::now();
let result = if progress_json {
// JSONL 进度输出:插件 wiki_generate 流式解析
code_repo_wiki::run_pipeline_with_progress(
config.as_deref(), output.as_deref(), force, &root,
&code_repo_wiki::GenerationMode::Full,
&|evt| {
println!(r#"{{"stage":"{}","progress":{}}}"#, evt.stage, evt.percent);
},
)?
} else {
code_repo_wiki::run_pipeline_with_progress(
config.as_deref(), output.as_deref(), force, &root,
&code_repo_wiki::GenerationMode::Full,
&|evt| {
// 文本模式阶段行(stderr);done 阶段由完成摘要行取代
if evt.stage != "done" {
tracing::info!("进度 [{}] {}%", stage_zh(evt.stage), evt.percent);
}
},
)?
};
println!(
"✓ 生成完成: 扫描 {} 个文件 / {} 个实体 / {} 页文档({}s)",
result.stats.files_scanned,
result.stats.total_entities,
result.documents.len(),
started.elapsed().as_secs()
);
}
Commands::Update { config, output, force, progress_json, dry_run, root } => {
// update 命令无外部 watch 事件,watch_paths 传空、change_kind 传 None
let root = resolve_root(root.as_deref())?;
// v17 t07:--dry-run 只做变更分析预览,不执行生成(无副作用)。
// 与 run_pipeline 的差异:跳过 LLM 与渲染,只输出将更新的文件/
// 模块清单——用户可先预览再决定是否真正执行。
if dry_run {
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root)?;
// scan_and_parse_at 返回 ScanOutput(v13 B5),取 insights 喂下游
let scan = code_repo_wiki::ingest::scan_and_parse_at(&root)?;
let graph = code_repo_wiki::analysis::build_graph(&scan.insights)?;
let inc = code_repo_wiki::incremental::run_incremental_update_at(
&root, &scan.insights, &graph, &cfg, &[],
)?;
println!(
"--dry-run: {} 个文件变更, {} 个模块受影响(未执行生成)",
inc.changed_files.len(),
inc.affected_modules.len()
);
for f in &inc.changed_files {
println!(" 变更: {}", f.display());
}
for m in &inc.affected_modules {
println!(" 受影响模块: {m}");
}
return Ok(());
}
// v44:update 文本模式与 generate 同构——阶段行走 stderr,
// 完成摘要走 stdout(no-op 早退分支保持「无文件变更」契约行,
// 不打印摘要——见下方分支判断)
let started = std::time::Instant::now();
let result = if progress_json {
// JSONL 进度输出:与 generate --progress-json 同构,供插件流式解析
code_repo_wiki::run_pipeline_with_progress(
config.as_deref(), output.as_deref(), force, &root,
&code_repo_wiki::GenerationMode::Incremental {
watch_paths: Vec::new(),
change_kind: None,
},
&|evt| {
println!(r#"{{"stage":"{}","progress":{}}}"#, evt.stage, evt.percent);
},
)?
} else {
code_repo_wiki::run_pipeline_with_progress(
config.as_deref(), output.as_deref(), force, &root,
&code_repo_wiki::GenerationMode::Incremental {
watch_paths: Vec::new(),
change_kind: None,
},
&|evt| {
if evt.stage != "done" {
tracing::info!("进度 [{}] {}%", stage_zh(evt.stage), evt.percent);
}
},
)?
};
// t03(v21):no-op 早退出口的 stdout 契约——lib.rs 在扫描前即
// 判定"无文件变更"并返回空结果(documents 空且扫描 0 文件,
// 与"空 diff 但已扫描"的普通更新路径可唯一区分)。外部 AI
// Coding Agent 以 stdout 为事实源:两种结果必须可区分——
// "增量更新完成"只属于真实执行路径,跳过时向 stdout 打印
// 明确消息且不再打印完成行(跳过细节仍走 stderr tracing)。
if result.documents.is_empty() && result.stats.files_scanned == 0 {
println!("无文件变更,跳过更新(no-op)");
} else {
println!(
"✓ 增量更新完成: 扫描 {} 个文件 / {} 页文档({}s)",
result.stats.files_scanned,
result.documents.len(),
started.elapsed().as_secs()
);
}
// D2(N2):update 尾部一致性校验——复用 lint 全部检查对产物做
// 全量复核(本轮受影响页 + 存量页)。增量更新只重建受影响模块,
// 跨页一致性问题(断链/引用漂移/符号漂移等)可能残留,此处让
// 用户立即可见;只告警不改变退出码("失败只告警"策略——产物
// 缺陷由 lint 门禁兜底拦截,update 主流程语义不受影响)。
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root)?;
let output_dir = cfg.output_dir();
let source_roots = code_repo_wiki::commands::source_roots(&root);
let issues = code_repo_wiki::output::lint::lint(output_dir, &source_roots);
// v14 D 组(t05 拍板):语义一致性检查(LLM 跨页矛盾,变更驱动——
// 只查本次 update 生成的受影响页;LLM 不可用/失败时"只告警"跳过,
// 语义检查是增强项,静态 lint 已覆盖机械问题)
if let Err(e) = code_repo_wiki::output::semantic_lint::check_semantic_consistency(
&cfg,
&result.documents,
)
.map(|semantic| {
for issue in &semantic {
tracing::warn!(" [{}] {}: {}", issue.kind, issue.path, issue.message);
}
}) {
tracing::warn!("语义一致性检查跳过(LLM 不可用或调用失败): {e}");
}
if !issues.is_empty() {
tracing::warn!(
"update 完成后产物检查发现 {} 个问题(不阻断本次更新,详情可用 `code-repo-wiki lint` 查看):",
issues.len()
);
for issue in &issues {
tracing::warn!(" [{}] {}: {}", issue.kind, issue.path, issue.message);
}
} else {
tracing::info!("update 完成后产物检查通过(全部检查无问题)");
}
}
Commands::Sync { config, root } => {
// sync = Git 内容 → 指纹库(不触发 LLM);与 update = 代码变更 → 增量生成 边界分离。
// 产物目录与 generate 同基准:load_config_rooted 统一将相对 output.dir
// 解析到 root(v17 F 组)——指纹键形态必须与生成状态键一致,否则
// 人工修改保护检测失效(旧实现 sync 相对 cwd 解析,root 化后错位)。
let root = resolve_root(root.as_deref())?;
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root)?;
code_repo_wiki::commands::sync_from_git(cfg.output_dir())?;
tracing::info!("同步完成 (--config {})", config.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| "默认链".into()));
}
Commands::Status { config, root } => {
// --root 提供时以 root 为产物目录基准(跨 cwd 运行 status 能定位正确产物;
// 缺省 root=cwd 行为不变)
let root = resolve_root(root.as_deref())?;
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root)?;
// 实际生效的配置文件路径:显式指定即其本身;缺省走三链
// (项目级 config.toml → 用户级 config.toml → 创建用户级)
let cfg_path = match config.as_deref() {
Some(p) => p.to_path_buf(),
None => code_repo_wiki::config::load_default_config(&root)?.0,
};
tracing::info!("配置加载成功: {}", cfg_path.display());
let report = code_repo_wiki::commands::status_report(&cfg, &root);
// ready 才报告页面统计与 lint 结果;未生成时引导运行 generate
if report.ready {
println!("Wiki 状态: 就绪");
println!("配置文件: {}", cfg_path.display());
println!("页面: {} 张,卡片: {} 张", report.wiki_pages, report.cards);
// v32 10.1:语义索引状态显式行(读降级标记;有标记=已降级+原因)
match code_repo_wiki::semantic_degraded_reason(&cfg) {
Some(reason) => println!("语义索引: 已降级(原因: {})", reason.trim()),
None => println!("语义索引: 正常"),
}
// v36 D3:LLM 状态显式行——provider=mock 意味着未配置真实
// LLM Key,页面内容由模板生成(无 AI 润色);显式提示防
// 止用户误以为已接入真实模型(静默降级是「不操心」的反面)
if cfg.llm.provider == code_repo_wiki::config::schema::LlmProviderType::Mock {
println!("LLM: 已降级(mock 模拟——未配置真实 LLM Key,页面由模板生成)");
} else {
println!("LLM: 正常");
}
// lint 产物健康检查结果(与 lint 命令同格式,问题退出码非 0)
for issue in &report.issues {
println!("- [{}] {}: {}", issue.kind, issue.path, issue.message);
}
if !report.issues.is_empty() {
anyhow::bail!("status: 发现 {} 个问题", report.issues.len());
}
} else {
println!("Wiki 状态: 未生成(运行 code-repo-wiki generate)");
println!("配置文件: {}", cfg_path.display());
}
}
Commands::Lint { config, root } => {
// lint 检查产物健康:孤儿页/断链/过时;三态退出码(v17 t07,docverity
// 模式):0 = 干净 / 1 = 有发现问题 / 2 = 工具问题(配置加载失败、
// 目录缺失——防止配置 typo 掩盖绿构建)
// --root 提供时以 root 为产物目录基准(同 status)。
let root = match resolve_root(root.as_deref()) {
Ok(r) => r,
Err(e) => {
eprintln!("lint: 工具问题: {}", e);
std::process::exit(2);
}
};
let cfg = match code_repo_wiki::load_config_rooted(config.as_deref(), &root) {
Ok(c) => c,
Err(e) => {
eprintln!("lint: 工具问题(配置加载失败): {}", e);
std::process::exit(2);
}
};
let output_dir = cfg.output_dir();
// v30+:源码根恒为仓库根(扫描范围已硬编码为全量遍历+内置过滤)
let source_roots = code_repo_wiki::commands::source_roots(&root);
let issues = code_repo_wiki::output::lint::lint(output_dir, &source_roots);
if issues.is_empty() {
println!("lint: 通过,无孤儿页/断链/过时问题");
} else {
for issue in &issues {
println!("lint [{}] {}: {}", issue.kind, issue.path, issue.message);
}
std::process::exit(1);
}
}
Commands::Doctor { config, root } => {
// 六查诊断(配置/产物可写/输出状态/Key/网络/版本漂移),逐项输出;
// 任一失败退出码 1(与 lint 三态同族,供脚本门禁)
let root = resolve_root(root.as_deref())?;
let checks = code_repo_wiki::doctor::run(config.as_deref(), &root)?;
for c in &checks {
println!("[{}] {}", if c.ok { "✓" } else { "✗" }, c.name);
if let Some(detail) = &c.detail {
println!(" {detail}");
}
}
let failed = checks.iter().filter(|c| !c.ok).count();
if failed > 0 {
anyhow::bail!("doctor: {} 项未通过", failed);
}
}
Commands::Key { env, config, root } => {
// key:交互式配置 LLM API key。写入目标固定为用户级
// config.toml——安全底线:明文凭据绝不写项目级
// config.toml(随 Git 共享)。--config 仅用于读取 provider
// 判定(如项目级 provider=mock 时提示无需 key)。
let root = resolve_root(root.as_deref())?;
code_repo_wiki::key::run(env, config.as_deref(), &root)?;
}
Commands::AstSearch { symbol, language, config, json, root } => {
// AST 精确符号查找:不依赖搜索索引,直接扫描源文件解析 AST 定位定义
let root = resolve_root(root.as_deref())?;
let results = code_repo_wiki::execute_ast_search(config.as_deref(), &root, &symbol, language.as_deref())?;
if json {
let json_results: Vec<serde_json::Value> = results.iter().map(|hit| {
serde_json::json!({
"name": hit.node.name,
"kind": hit.node.kind.as_str(),
"file": hit.node.file_path,
"lines": hit.node.line_range,
"signature": hit.node.signature,
"source": hit.source,
})
}).collect();
println!("{}", serde_json::to_string_pretty(&json_results)?);
} else {
if results.is_empty() {
println!("未找到符号 \"{symbol}\" 的定义");
}
for (i, hit) in results.iter().enumerate() {
let sig = hit.node.signature.as_deref().unwrap_or(&hit.node.name);
let loc = match (&hit.node.file_path, hit.node.line_range) {
(Some(f), Some((s, e))) => format!("{f}:{s}-{e}"),
(Some(f), None) => f.clone(),
_ => "(unknown)".to_string(),
};
println!("{}. {sig} — {loc}", i + 1);
}
}
}
Commands::Export { config, output, skip_generate, root } => {
let root = resolve_root(root.as_deref())?;
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root)?;
if skip_generate {
// 从导出快照恢复导出(票 06):不重跑生成流水线。
// render_all 每次写盘后同步写 .state/export_snapshot.json,
// 快照缺失时明确报错(不静默回退重生成——回退会掩盖
// 快照契约被破坏的事实)。
let snapshot_path =
code_repo_wiki::output::export_snapshot_path(cfg.output_dir());
// 票 04 陈旧检测:快照 mtime 早于任一 wiki 页 mtime = 产物在
// 快照之后被更新(快照写入失败/被外部改动/产物被手动编辑),
// 继续导出会静默输出过期内容——显式报错引导重新生成。
if let (Ok(snapshot_mtime), Some(latest_page)) = (
std::fs::metadata(&snapshot_path).and_then(|m| m.modified()),
code_repo_wiki::output::latest_wiki_page_mtime(cfg.output_dir()),
) && snapshot_mtime < latest_page
{
anyhow::bail!(
"导出快照过期(快照写入时间早于最新 wiki 页),请重新运行 `code-repo-wiki generate` 或 `code-repo-wiki update` 后再导出"
);
}
let content = std::fs::read_to_string(&snapshot_path).with_context(|| {
format!(
"导出快照不存在,请先运行 `code-repo-wiki generate` 或 `code-repo-wiki update`: {}",
snapshot_path.display()
)
})?;
let snapshot: code_repo_wiki::output::ExportSnapshot =
serde_json::from_str(&content).with_context(|| "解析导出快照失败")?;
// 票 10:快照版本契约校验——未来格式演进时旧版本可被
// 显式拒绝(当前仅版本 1;缺失字段的旧文件会被 serde
// 默认值补齐后误读,故版本不符必须硬性报错而非容错)
if snapshot.version != 1 {
anyhow::bail!(
"导出快照版本 {} 不受支持(当前支持: 1),请重新运行 `code-repo-wiki generate` 或 `code-repo-wiki update`",
snapshot.version
);
}
code_repo_wiki::output::html::export_html(
&snapshot.documents,
&snapshot.cards,
&snapshot.modules,
&cfg,
)?;
} else {
let result = code_repo_wiki::run_pipeline(
config.as_deref(), output.as_deref(), false, &root,
&code_repo_wiki::GenerationMode::Full,
)?;
code_repo_wiki::output::html::export_html(
&result.documents,
&result.cards,
&code_repo_wiki::output::export_modules(&result.graph, &result.cards),
&cfg,
)?;
}
tracing::info!("HTML 导出完成 (--config {})", config.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| "默认链".into()));
}
Commands::Note { text, config, root } => {
// --root 提供时以 root 为产物目录基准(同 status/lint)
let root = resolve_root(root.as_deref())?;
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root)?;
code_repo_wiki::commands::append_note(
cfg.output_dir(),
&cfg.wiki.language,
&text,
)?;
tracing::info!("知识记录已写入 (--config {})", config.as_deref().map(|p| p.display().to_string()).unwrap_or_else(|| "默认链".into()));
}
Commands::Watch { config, root } => {
let root = resolve_root(root.as_deref())?;
// v36 D5:watch 常驻自愈——监听循环崩溃(notify 初始化失败/
// 事件循环错误)时指数退避自动重启(5s 起、上限 60s);
// Ctrl-C 优雅退出(run_watch 返回 Ok)直接结束。
// watch 是「自动维护」的最后一环:崩溃后静默消失会让 wiki
// 从此停更(无人知道)——自愈循环兜住这个缺口。
let mut delay = std::time::Duration::from_secs(5);
loop {
match code_repo_wiki::run_watch(config.as_deref(), &root) {
Ok(()) => break,
Err(e) => {
eprintln!("code-repo-wiki: watch 监听循环异常退出: {e}");
eprintln!(
"code-repo-wiki: {} 秒后自动重启监听(Ctrl-C 退出)",
delay.as_secs()
);
std::thread::sleep(delay);
delay = std::time::Duration::from_secs((delay.as_secs() * 2).min(60));
}
}
}
}
Commands::Search { query, top_k, config, json, engine, root } => {
// 解析引擎类型:优先用 CLI 参数,否则取默认常量 SEARCH_DEFAULT_ENGINE
//(v36 起为 Hybrid;hybrid 无 embed key 时自动降级纯 text)
let root = resolve_root(root.as_deref())?;
let engine_type = match engine.as_deref() {
Some("text") => code_repo_wiki::config::schema::SearchEngineType::Text,
Some("semantic") => code_repo_wiki::config::schema::SearchEngineType::Semantic,
Some("hybrid") => code_repo_wiki::config::schema::SearchEngineType::Hybrid,
Some(other) => anyhow::bail!("不支持的搜索引擎: {other}(可选: text/semantic/hybrid)"),
None => code_repo_wiki::config::schema::SEARCH_DEFAULT_ENGINE,
};
// CLI 显式 -k 优先,未传时回退硬编码默认 SEARCH_DEFAULT_TOP_K
// N17:top_k 下限收敛到 1(top_k=0 的搜索调用无意义,返回空结果)
let top_k = top_k.unwrap_or(code_repo_wiki::config::schema::SEARCH_DEFAULT_TOP_K).max(1);
let results = code_repo_wiki::execute_search(config.as_deref(), &root, &query, top_k, &engine_type)?;
if json {
// JSON 格式输出(供 OpenCode 插件解析)
let json_results: Vec<serde_json::Value> = results.iter().map(|hit| {
serde_json::json!({
"name": hit.node.name,
"kind": hit.node.kind.as_str(),
"score": hit.score,
"file": hit.node.file_path,
"lines": hit.node.line_range,
"signature": hit.node.signature,
"source": hit.source,
"callers": hit.callers,
"callees": hit.callees,
})
}).collect();
println!("{}", serde_json::to_string_pretty(&json_results)?);
} else {
// 表格格式输出(人类可读)
if results.is_empty() {
println!("未找到匹配结果");
} else {
println!("{:<4} {:<30} {:<12} {:<8} 文件", "#", "名称", "类型", "分数");
println!("{}", "-".repeat(80));
for (i, hit) in results.iter().enumerate() {
let file = hit.node.file_path.as_deref().unwrap_or("-");
println!("{:<4} {:<30} {:<12} {:<8.2} {}",
i + 1, hit.node.name, hit.node.kind.as_str(), hit.score, file);
}
}
// v32 10.1:语义索引降级显式提示(文本模式)——降级标记由
// 最近一次 generate/update 写入(见 lib.rs 标记区);有标记
// 时提示原因,避免用户误以为语义结果可用。load_config_rooted
// 失败(配置缺失/损坏)与无标记一样静默——搜索本身已成功,
// 提示只是附加信息,不让配置错误打断结果展示。
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root).ok();
if let Some(reason) = cfg.and_then(|c| code_repo_wiki::semantic_degraded_reason(&c)) {
println!("语义索引已降级(原因: {})", reason.trim());
}
}
}
Commands::Install { claude, codex, root } => {
// v25 起 init 并入 install:先确保用户级默认配置就绪
// (缺失自动创建,含项目级 config.toml 覆盖链语义),
// 再执行集成安装(v33 合并版:OpenCode 插件 + 多 Agent MCP
// + AGENTS.md + git hooks;--claude/--codex 扩展)。
let root = resolve_root(root.as_deref())?;
let (source, _config) = code_repo_wiki::config::load_default_config(&root)?;
// 配置链解析完成(来源可能是用户级或项目级 config.toml——
// 项目级存在时优先,用户级缺失不自动创建,见 load_default_config)
tracing::info!("配置链就绪(来源: {})", source.display());
let opts = code_repo_wiki::commands::InstallOptions { claude, codex };
code_repo_wiki::commands::install(&root, &opts)?;
}
Commands::Uninstall { force, root } => {
let root = resolve_root(root.as_deref())?;
code_repo_wiki::commands::uninstall(force, &root)?;
}
Commands::Mcp { config, root } => {
// MCP stdio server:阻塞直到客户端断开。异步运行时由库内
// get_global_runtime 提供(与流水线共用,避免二次初始化)。
let root = resolve_root(root.as_deref())?;
let rt = code_repo_wiki::get_global_runtime();
rt.block_on(code_repo_wiki::mcp::serve_stdio(config.as_deref(), root))?;
}
Commands::Card { action, root } => {
use code_repo_wiki::generate::card as card_cmd;
// CLI 枚举转业务枚举(config 路径在匹配时提取,供 run_card_command 使用)
let (config, action) = match action {
CardAction::Generate { module, config } => {
(config, card_cmd::CardAction::Generate { module })
}
CardAction::Modify { module, instruction, reference, config } => (
config,
card_cmd::CardAction::Modify { module, instruction, references: reference },
),
CardAction::Supplement { module, instruction, reference, config } => (
config,
card_cmd::CardAction::Supplement { module, instruction, references: reference },
),
CardAction::Rewrite { module, instruction, reference, config } => (
config,
card_cmd::CardAction::Rewrite { module, instruction, references: reference },
),
};
let root = resolve_root(root.as_deref())?;
code_repo_wiki::run_card_command(config.as_deref(), &root, &action)?;
}
Commands::Bench { root, repo_name, config, json, judge, rubrics_only, repodoc } => {
// 评测基准(U10):五维自动评测。root 必填(评测对象仓库根),
// ProjectRoot::new 会校验目录存在性(N7)。config 缺省走默认
// 配置链(E 组:项目级 → 全局 → 创建全局);repo_name 缺省取
// root 目录名。
// Update Recall 回放前有工作区干净检查(安全闸,事故教训),
// 脏工作区会明确报错拒绝评测。
// --rubrics-only(v21 D 组):跳过回放只做裁判层——大仓库
// 评测成本控制(clap conflicts_with 已保证与 --judge 互斥)。
// --repodoc(v32 6.4 FR-101):RepoDocBench 对齐五维报告,
// 强制 LLM 裁判(judge 提升)并在文本模式前置五维摘要。
let root = code_repo_wiki::project::ProjectRoot::new(root);
let cfg = code_repo_wiki::load_config_rooted(config.as_deref(), &root)?;
let repo_name = repo_name.unwrap_or_else(|| {
root.path()
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string())
});
// v32(6.4):--repodoc 隐含 judge=true(五维含 TQS LLM 裁判)
let judge = judge || repodoc;
let report = if rubrics_only {
code_repo_wiki::bench::run_rubrics_only(&root, &cfg, &repo_name)?
} else {
code_repo_wiki::bench::run_bench(config.as_deref(), &root, &cfg, &repo_name, judge)?
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
// v32(6.4):--repodoc 文本模式前置五维聚合摘要
if repodoc {
print!("{}", code_repo_wiki::bench::render_repodoc(&report));
}
println!("{}", code_repo_wiki::bench::render_markdown(&report));
}
}
Commands::BenchManifest { manifest, config, json, work_dir } => {
// 清单批量跑分(v21 E 组):模板配置只取 scope/llm/provider 等
// 语义字段,产物目录按仓库覆盖为 work_dir/<name>-out/。
// work_dir 缺省系统临时目录(远程 clone 落地)。
let work_dir = work_dir.unwrap_or_else(|| {
std::env::temp_dir().join("code-repo-wiki-bench-manifest")
});
let cfg = match config {
Some(path) => code_repo_wiki::load_config_rooted(Some(&path), &code_repo_wiki::project::ProjectRoot::new(std::env::current_dir()?))?,
None => {
// 缺省走默认加载链(项目级 → 用户级 → 创建用户级)
let root = code_repo_wiki::project::ProjectRoot::new(std::env::current_dir()?);
code_repo_wiki::load_config_rooted(None, &root)?
}
};
let entries = code_repo_wiki::bench::manifest::parse_manifest(&manifest)?;
let report = code_repo_wiki::bench::manifest::run_manifest(&entries, &cfg, &work_dir)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", code_repo_wiki::bench::manifest::render_manifest_markdown(&report));
}
}
}
Ok(())
}