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
use crate::generate::chunk::Chunk;
use crate::generate::llm::Message;
use crate::model::{KnowledgeGraph, ModuleCluster};

/// 生成模块摘要的系统 prompt
///
/// v45 提示词工程优化:指令前置 + ### 分节(OpenAI 官方最佳实践;
/// Lost in the Middle 位置效应——指令放开头利用首部注意力)。
fn module_summary_system_prompt(language: &str) -> String {
    let output_lang = if language == "zh" { "简体中文" } else { language };
    format!(
        r#"### 角色
你是一个资深软件工程师,负责分析代码并生成模块摘要。

### 任务
依据输入的实体列表、导入语句与关联文件,识别模块的核心职责、边界与对外依赖,
并总结关键设计决策与模式。

### 输出格式
## 模块概述
简要描述这个模块的职责和功能。

## 核心实体
列出所有重要的结构体、trait、函数,每条一行:
- `实体名`(类型)— 描述

## 依赖关系
列出这个模块引用的外部模块和依赖。

## 设计要点
关键的设计决策和模式。

### 约束
- 只基于输入信息作答;输入未提供的内容不要臆测。
- 请用 {} 输出。"#,
        output_lang
    )
}

/// 生成模块摘要的 user prompt
fn module_summary_user_prompt(chunk: &Chunk) -> String {
    let mut parts = Vec::new();

    parts.push(format!("模块路径: {}", chunk.module_path.join("::")));

    if !chunk.entities.is_empty() {
        parts.push("\n## 实体列表".to_string());
        for entity in &chunk.entities {
            let doc = entity
                .doc_comment
                .as_deref()
                .map(|d| d.lines().next().unwrap_or(""))
                .unwrap_or("");
            parts.push(format!(
                "- {} ({}): {} [行 {}..{}]",
                entity.name, entity.kind, doc, entity.line_start, entity.line_end
            ));
        }
    }

    if !chunk.imports.is_empty() {
        parts.push("\n## 导入语句".to_string());
        for import in &chunk.imports {
            parts.push(format!("- {}", import.source));
        }
    }

    if !chunk.file_paths.is_empty() {
        parts.push("\n## 关联文件".to_string());
        for path in &chunk.file_paths {
            parts.push(format!("- {}", path.display()));
        }
    }

    parts.join("\n")
}

/// 生成模块摘要的 prompt
pub fn module_summary_prompt(
    chunk: &Chunk,
    language: &str,
) -> Vec<Message> {
    let system = module_summary_system_prompt(language);
    vec![
        Message::system(system),
        Message::user(module_summary_user_prompt(chunk)),
    ]
}

/// 生成架构概览的 system prompt
///
/// v45:指令前置 + ### 分节;模块真实性约束整段保留(契约字面不变)。
fn architecture_overview_system_prompt(language: &str) -> String {
    let output_lang = if language == "zh" { "简体中文" } else { language };
    format!(
        r#"### 角色
你是一个资深软件架构师,负责分析整个项目的模块结构并生成架构概览文档。

### 任务
基于输入的模块聚类信息和依赖关系,分析架构风格、模块划分、依赖关系与数据流,
并推断关键架构决策。

### 输出格式
# 项目架构概览

## 架构风格
描述项目采用的架构风格(如分层架构、模块化单体、微服务等)。

## 模块划分
列出所有模块及其职责:
- `模块名` — 职责描述

## 模块间依赖关系
描述各个模块之间的依赖关系和通信方式。

## 数据流
数据在模块间的流转方式。

## 架构决策
可以从此架构中推断出的关键架构决策。

### 约束
**模块真实性约束(必须遵守)**:模块划分小节只列出输入模块聚类信息中给出的
模块名,不得添加、改名或合并输入中不存在的模块。

请用 {} 输出。保留 Markdown 格式。"#,
        output_lang
    )
}

/// 生成架构概览的 user prompt
fn architecture_overview_user_prompt(modules: &[ModuleCluster], graph: &KnowledgeGraph) -> String {
    let mut parts = Vec::new();

    parts.push("## 模块聚类信息".to_string());
    for module in modules {
        parts.push(format!(
            "- {} (内聚度: {:.2}, 耦合度: {:.2}, 节点数: {})",
            module.name,
            module.cohesion,
            module.coupling,
            module.node_ids.len()
        ));
    }

    parts.push("\n## 图统计".to_string());
    parts.push(format!("- 总节点数: {}", graph.graph.node_count()));
    parts.push(format!("- 总边数: {}", graph.graph.edge_count()));

    parts.join("\n")
}

/// 生成架构概览的 prompt
/// 生成单模块一行职责描述的 prompt(自底向上合成的一层:
/// 架构/概览基于各模块职责描述输出,而非只有模块名+节点数)
///
/// 输入 = 模块名 + 实体名列表(≤30,LLM 据此判断职责边界),
/// 输出约束 = 一句话、≤30 字、只输出描述文本本身(无前缀/引号/换行)。
pub fn module_description_prompt(
    module_name: &str,
    entity_names: &[String],
    language: &str,
) -> Vec<Message> {
    let entities = if entity_names.is_empty() {
        "(无实体)".to_string()
    } else {
        entity_names.join(", ")
    };
    vec![
        Message::system(format!(
            "你是代码架构分析专家。请用一句中文({} 字以内)概括给定模块的职责。\
             只输出职责描述本身,不要前缀、引号或换行。",
            if language == "zh" { 30 } else { 60 }
        )),
        Message::user(format!(
            "模块名: {module_name}\n包含实体: {entities}\n\n请输出该模块的一句话职责描述。"
        )),
    ]
}

/// 生成架构概览页面(自顶向下的系统级描述)
pub fn architecture_overview_prompt(
    modules: &[ModuleCluster],
    graph: &KnowledgeGraph,
    language: &str,
) -> Vec<Message> {
    let system = architecture_overview_system_prompt(language);
    vec![
        Message::system(system),
        Message::user(architecture_overview_user_prompt(modules, graph)),
    ]
}

/// 生成 Knowledge Card 的 system prompt
///
/// v45:指令前置 + ### 分节;新增「输出原始 JSON」约束(避免 LLM 复制
/// 示例中的 Markdown 代码块包裹,结构合规不押在措辞威胁上——示例仍在,
/// 明确禁止包裹);实体真实性约束整段保留(契约字面不变)。
fn knowledge_card_system_prompt(language: &str) -> String {
    let output_lang = if language == "zh" { "简体中文" } else { language };
    format!(
        r#"### 角色
你是一个代码分析专家,负责生成结构化的 Knowledge Card。
Knowledge Card 是给 AI Agent 阅读的模块级结构化摘要。

### 任务
按以下步骤**在内部**完成分析(不要输出思考过程,只输出最终 JSON):
1. 归纳模块职责与边界,形成一句话总结;
2. 识别关键实体及其对外契约(可见性、职责);
3. 推断设计模式、技术栈与编码规范;
4. 若输入中含"人工修改待同步"记录,将其内容纳入描述(不要删除记录本身)。

### 输出格式
严格按以下 JSON 格式输出(字段缺失时省略可选字段,不输出 null 占位):

```json
{{
  "summary": "模块功能的一句话总结",
  "key_entities": [
    {{"name": "实体名", "kind": "结构体/函数/Trait", "visibility": "public/private/crate", "doc": "文档描述"}}
  ],
  "design_patterns": ["用到的设计模式"],
  "todo_notes": ["待办事项或注意点"],
  "coding_spec": "该模块遵循的编码规范(无则省略该字段)",
  "tech_stack": ["该模块用到的技术栈,如 tokio/serde/petgraph"],
  "architecture": "该模块的内部架构或关键设计说明(无则省略该字段)"
}}
```

### 约束
**实体真实性约束(必须遵守)**:key_entities 只允许列出输入实体信息中真实存在的
实体(名称与输入一致),不得编造不存在的实体;找不到时列表可以为空。

输出原始 JSON 对象本身——不要用 Markdown 代码块包裹,不要添加任何前后缀文字。
描述性字段请用 {} 输出。"#,
        output_lang
    )
}

/// 生成 Knowledge Card 的 prompt
///
/// pending_manual_edits 为旧卡片上"人工修改待同步"记录(非空时注入 user 消息,
/// 要求 LLM 生成/更新卡片时考虑这些修改;记录本身由增量管道维护,不由 LLM 产出)。
pub fn knowledge_card_prompt(
    chunk: &Chunk,
    language: &str,
    pending_manual_edits: &[String],
) -> Vec<Message> {
    let system = knowledge_card_system_prompt(language);
    let mut user = module_summary_user_prompt(chunk);
    // 人工修改待同步:只在存在记录时注入,避免空节污染输入
    if !pending_manual_edits.is_empty() {
        user.push_str("\n\n## 人工修改待同步\n\n");
        user.push_str(
            "以下页面被人工修改,与代码最新状态可能不一致。\
             请结合这些修改生成卡片描述(如更新摘要、实体说明),但不要删除下述记录本身:\n",
        );
        for note in pending_manual_edits {
            user.push_str(&format!("- {note}\n"));
        }
    }
    vec![Message::system(system), Message::user(user)]
}

/// 生成卡片编辑/补充/重写指令的 prompt
///
/// mode 为 "modify"/"supplement"/"rewrite":
/// - modify:现有卡片内容 + 指令
/// - supplement:现有内容保留,末尾追加新内容
/// - rewrite:仅指令 + 模块来源信息(不携带现有内容)
///
/// references 为参考材料段落(空字符串表示无参考文件)。
pub fn edit_card_prompt(
    mode: &str,
    module: &str,
    existing: &str,
    instruction: &str,
    references: &str,
    language: &str,
) -> Vec<Message> {
    let system = format!(
        r#"你是一个代码分析专家,负责编辑 Knowledge Card。
Knowledge Card 是给 AI Agent 阅读的模块级结构化摘要,使用固定 Markdown 格式:

# 模块名

## 摘要
模块功能总结

## 核心实体
- `实体名`(类型)— 描述

## 相关文件
- 文件路径

## 设计模式
- 模式

缺失的字段省略对应小节。请直接输出编辑后的完整卡片 Markdown,不要代码块包裹,不要添加无关内容。请用 {} 语言输出描述。"#,
        language
    );

    // rewrite 不携带现有内容(仅指令 + 模块来源信息);其余模式携带并给出保留/修改语义
    let mut user = if mode == "rewrite" {
        format!("模块: {module}\n\n指令: {instruction}\n\n忽略任何旧版本内容,全量重写该模块的卡片。")
    } else {
        let hint = if mode == "supplement" {
            "保留现有卡片内容不变,按指令在末尾追加新内容"
        } else {
            "按指令修改现有卡片内容,其余部分保持不变"
        };
        format!("模块: {module}\n\n指令: {instruction}{hint}\n\n## 现有卡片内容\n{existing}")
    };
    if !references.is_empty() {
        user.push_str(&format!("\n\n## 参考材料\n{references}"));
    }
    vec![Message::system(system), Message::user(user)]
}

/// 生成 Wiki Page 的 system prompt
///
/// v45:指令前置 + ### 分节;防幻觉补强(信息不足显式标注而非编造——
/// Anthropic reduce-hallucinations 的「允许说不知道」写法);输出语言显式化;
/// 源码引用契约整段保留(契约字面不变)。
fn wiki_page_system_prompt(language: &str) -> String {
    let output_lang = if language == "zh" { "简体中文" } else { language };
    format!(
        r#"### 角色
你是一个技术文档写手,负责生成项目 Wiki 页面。
Wiki 页面是给人类开发者阅读的叙述性文档。

### 任务
基于模块信息和卡片摘要,按输出格式生成 Wiki 页面。

### 输出格式
# 模块名称

## 概述
用 2-3 句话描述模块的职责和功能。

## 核心实体
- `StructName` — 描述
- `fn_name()` — 描述
- `TraitName` — 描述

## 依赖关系
- `模块A` — 依赖说明

## 使用方式
简要说明如何使用这个模块。

### 约束
**源码引用契约(必须遵守)**
- 提及任何具体函数、结构体、文件时,必须携带真实存在的源码引用:
  `相对路径:行号`(如 `src/fs.rs:28`)或 `相对路径:起始行-结束行`(如 `src/fs.rs:28-45`),
  写在提及处所在行内。
- 引用必须真实存在:只引用输入实体列表/关联文件中给出的文件与行号,
  不得编造不存在的文件或行号。
- 每个小节至少包含一条引用。

**信息不足时的处理**:输入中没有依据的内容(如某实体用途不明、依赖不确定),
在对应位置写「(信息不足)」并保持简洁,不要编造。

请用 {} 输出。保持简洁、清晰。"#,
        output_lang
    )
}

/// 实体签名单行化(v32 7.1 FR-201 签名级片段注入)
///
/// 签名是多行代码文本:压成单行避免破坏清单逐行结构;超 8 行或超 160 字符
/// 截断至 160 字符并追加 …(边界:签名缺失/空白 → 空串,不输出占位)。
/// 只读格式化,不改变 chunk 结构与 insights_cache 格式。
fn entity_signature_line(e: &crate::ingest::parser::Entity) -> String {
    let Some(raw) = &e.signature else {
        return String::new();
    };
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return String::new();
    }
    let line_count = trimmed.lines().count();
    // lines() 会剥行尾 \r(CRLF 源文件签名实测含 \r\n,parser 原始节点文本
    // 不做归一化——ingest/mod.rs read_to_string 亦不归一化),用 join 压平
    // 同时保证行数与压平一致(reviewer LOW 修复)。
    let mut flat = trimmed.lines().collect::<Vec<_>>().join(" ");
    if line_count > 8 || flat.chars().count() > 160 {
        flat = flat.chars().take(160).collect();
        flat.push('');
    }
    format!(",签名: {flat}")
}

/// 生成 Wiki Page 的 user prompt
///
/// 输入包含「实体引用清单」:实体名+类型+文件路径:行号(真源),
/// 是源码引用契约(不得编造)唯一允许的引用来源——此前只传卡片摘要,
/// 摘要不含行号,LLM 无法兑现契约只能编造(v29 实测 bad-citation 来源)。
/// v32 7.1 起每条追加签名级片段(≤8 行/≤160 字符),供 LLM 精确引用签名
/// 而无需猜测(FR-201)。实体过多时截断前 80 条并注明总数,避免输入超长。
fn wiki_page_user_prompt(chunk: &Chunk, module_summary: &str, notes: &[String]) -> String {
    let mut entity_lines: Vec<String> = chunk
        .entities
        .iter()
        .enumerate()
        .map(|(i, e)| {
            let sig = entity_signature_line(e);
            match chunk.entity_sources.get(i) {
                Some(path) => format!("- `{}` ({}) — {}:{}{}", e.name, e.kind, path.display(), e.line_start, sig),
                None => format!(
                    "- `{}` ({}) — 第 {}-{} 行(所属文件未记录){}",
                    e.name, e.kind, e.line_start, e.line_end, sig
                ),
            }
        })
        .take(80)
        .collect();
    if chunk.entities.len() > 80 {
        entity_lines.push(format!("- …共 {} 个实体,仅列出前 80 个", chunk.entities.len()));
    }
    // v32 9.2:项目引导说明([wiki.guide].notes)——逐条注入 user 消息,
    // 引导 LLM 按项目约定撰写页面(命名规范/必写小节/注意事项)。空列表
    // 时不生成该节,保持旧 prompt 形态(零破坏)。
    let guide_section = if notes.is_empty() {
        String::new()
    } else {
        format!(
            "\n\n## 项目引导说明\n{}\n",
            notes
                .iter()
                .map(|n| format!("- {}", n))
                .collect::<Vec<_>>()
                .join("\n")
        )
    };
    format!(
        "模块路径: {}\n\n## 代码信息\n实体数: {}, 文件数: {}\n\n## 实体引用清单\n{}\n\n## 卡片摘要\n{}{}",
        chunk.module_path.join("::"),
        chunk.entity_count(),
        chunk.file_paths.len(),
        entity_lines.join("\n"),
        module_summary,
        guide_section
    )
}

/// 生成 Wiki Page 的 prompt
pub fn wiki_page_prompt(
    chunk: &Chunk,
    module_summary: &str,
    language: &str,
    notes: &[String],
) -> Vec<Message> {
    let system = wiki_page_system_prompt(language);
    let user = wiki_page_user_prompt(chunk, module_summary, notes);
    vec![Message::system(system), Message::user(user)]
}

/// 生成数据库 Schema 文档的 system prompt
///
/// 要求输出表结构 Markdown 与 Mermaid erDiagram 代码块。
pub fn schema_doc_system_prompt(language: &str) -> String {
    format!(
        r#"你是一个数据库专家,负责分析 SQL 迁移文件并生成 Schema 文档。

请基于输入的建表语句,输出以下格式的 Markdown:

# 数据库 Schema 文档

## 表结构
对每张表用表格列出字段:列名 | 类型 | 约束 | 说明

## 关系说明
描述表之间的外键关系和约束。

## ER 图
用 Mermaid erDiagram 代码块画出实体关系图。

请用 {} 语言输出。保留 Markdown 与 Mermaid 代码块格式。"#,
        language
    )
}

/// 生成数据库 Schema 文档的 prompt
///
/// user 消息包含 SQL 文件路径与切分出的建表语句块。
pub fn schema_doc_prompt(
    path: &std::path::Path,
    blocks: &[&str],
    language: &str,
) -> Vec<Message> {
    let system = schema_doc_system_prompt(language);
    let mut user = format!("SQL 文件路径: {}\n\n## 建表语句块\n", path.display());
    for (i, block) in blocks.iter().enumerate() {
        user.push_str(&format!("### 语句块 {}\n```sql\n{}\n```\n\n", i + 1, block));
    }
    vec![Message::system(system), Message::user(user)]
}

// 实体摘要 prompt 已删除(v31):随 generate_entity_summaries 一并移除——
// Entity.summary 字段零消费者,每实体一次 LLM 调用纯浪费(见 mod.rs 注释)。

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

    /// 构造指定模块路径的空 Chunk(测试辅助)
    ///
    /// 实体/导入/依赖留空即可,prompt 层测试只关心模块路径与 notes/sections 注入。
    fn make_test_chunk(module_path: &[&str]) -> Chunk {
        Chunk {
            module_path: module_path.iter().map(|s| s.to_string()).collect(),
            entities: vec![],
            imports: vec![],
            dependencies: vec![],
            file_paths: vec![],
            entity_sources: vec![],
        }
    }

    #[test]
    fn test_knowledge_card_prompt_injects_pending_manual_edits() {
        let chunk = make_test_chunk(&["src", "config"]);
        // 存在记录:user 消息包含"人工修改待同步"节与记录内容
        let pending = vec!["人工修改待同步: wiki/zh/src_config.md 内容摘要: 用户改的".into()];
        let messages = knowledge_card_prompt(&chunk, "zh", &pending);
        let user = &messages[1].content;
        assert!(user.contains("## 人工修改待同步"));
        assert!(user.contains("wiki/zh/src_config.md"));
        // 无记录:不注入该节(避免空节)
        let messages = knowledge_card_prompt(&chunk, "zh", &[]);
        assert!(!messages[1].content.contains("人工修改待同步"));
    }

    #[test]
    fn test_schema_doc_prompt_contains_path_and_blocks() {        let blocks = vec!["CREATE TABLE users (\n    id INTEGER\n);"];
        let messages = schema_doc_prompt(
            std::path::Path::new("db/migrations/001_init.sql"),
            &blocks,
            "zh",
        );
        let user = &messages[1].content;
        assert!(user.contains("db/migrations/001_init.sql"));
        assert!(user.contains("CREATE TABLE users"));
        assert!(user.contains("```sql"));
        assert!(messages[0].content.contains("erDiagram"));
    }

    /// 防编造契约(A1 补强):卡片与架构 prompt 必须显式约束实体/模块真实性,
    /// 防止 LLM 输出输入中不存在的实体名或模块名(anti-fabrication 契约)。
    #[test]
    fn test_anti_fabrication_constraints_in_card_and_architecture_prompts() {
        let chunk = make_test_chunk(&["src", "config"]);
        let card_messages = knowledge_card_prompt(&chunk, "zh", &[]);
        assert!(
            card_messages[0].content.contains("不得编造"),
            "卡片 prompt 必须含实体真实性约束: {}",
            card_messages[0].content
        );

        let arch = architecture_overview_prompt(&[], &KnowledgeGraph::default(), "zh");
        assert!(
            arch[0].content.contains("不得添加"),
            "架构 prompt 必须含模块真实性约束: {}",
            arch[0].content
        );
    }

    /// v45 提示词工程优化契约:所有 system prompt 指令前置 + ### 分节;
    /// 卡片 prompt 明确「输出原始 JSON」;wiki prompt 含信息不足处理。
    #[test]
    fn test_v45_prompt_engineering_structure() {
        let chunk = make_test_chunk(&["src", "alpha"]);

        // 分节结构:四个主要 system prompt 均含 ### 角色
        let card = knowledge_card_prompt(&chunk, "zh", &[]);
        assert!(card[0].content.contains("### 角色"), "卡片 prompt 应分节: {}", card[0].content);
        let wiki = wiki_page_prompt(&chunk, "摘要", "zh", &[]);
        assert!(wiki[0].content.contains("### 角色"), "wiki prompt 应分节: {}", wiki[0].content);
        let arch = architecture_overview_prompt(&[], &KnowledgeGraph::default(), "zh");
        assert!(arch[0].content.contains("### 角色"), "架构 prompt 应分节: {}", arch[0].content);
        let summary = module_summary_prompt(&chunk, "zh");
        assert!(summary[0].content.contains("### 角色"), "摘要 prompt 应分节: {}", summary[0].content);

        // 卡片:输出原始 JSON(不包 Markdown 代码块)
        assert!(
            card[0].content.contains("输出原始 JSON"),
            "卡片 prompt 必须明确原始 JSON 约束: {}",
            card[0].content
        );
        assert!(
            card[0].content.contains("不要用 Markdown 代码块包裹"),
            "卡片 prompt 必须禁止代码块包裹: {}",
            card[0].content
        );

        // wiki:信息不足显式标注(防幻觉——允许说不知道)
        assert!(
            wiki[0].content.contains("信息不足"),
            "wiki prompt 必须含信息不足处理: {}",
            wiki[0].content
        );

        // 输出语言显式化:zh → 简体中文
        assert!(
            card[0].content.contains("简体中文"),
            "zh 语言必须显式化为简体中文: {}",
            card[0].content
        );
        // 非 zh 语言原样保留
        let card_en = knowledge_card_prompt(&chunk, "en", &[]);
        assert!(
            card_en[0].content.contains("请用 en 输出"),
            "非 zh 语言原样: {}",
            card_en[0].content
        );
    }

    /// 实体引用清单(A8):wiki page 的 user 输入必须携带实体名+文件:行号真源,
    /// 引用契约才能兑现(LLM 不得编造,且输入中确有其物可引)。
    #[test]
    fn test_wiki_page_user_prompt_contains_entity_reference_list() {
        let mut chunk = make_test_chunk(&["src", "alpha"]);
        chunk.entities = vec![crate::ingest::parser::Entity {
            name: "alpha_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 3,
            doc_comment: None,
            signature: None,
            visibility: None,
        }];
        chunk.entity_sources = vec![std::path::PathBuf::from("src/alpha.rs")];
        let user = wiki_page_user_prompt(&chunk, "卡片摘要", &[]);
        assert!(
            user.contains("src/alpha.rs:1"),
            "引用清单必须含文件:行号: {}",
            user
        );
        assert!(user.contains("alpha_fn"));
        // 无文件记录时的诚实标注路径(不得编造文件)
        chunk.entity_sources = vec![];
        let user = wiki_page_user_prompt(&chunk, "卡片摘要", &[]);
        assert!(user.contains("所属文件未记录"));
    }

    /// 项目引导说明注入(v32 9.1/9.2 FR-402):notes 非空时 user 消息
    /// 追加「项目引导说明」节(逐条列出);空列表不生成该节(零破坏)。
    #[test]
    fn test_wiki_page_user_prompt_injects_guide_notes() {
        let chunk = Chunk {
            module_path: vec!["src".into(), "alpha".into()],
            entities: vec![crate::ingest::parser::Entity {
                name: "alpha_fn".into(),
                kind: "fn".into(),
                line_start: 1,
                line_end: 3,
                doc_comment: None,
                signature: None,
                visibility: None,
            }],
            imports: vec![],
            dependencies: vec![],
            entity_sources: vec![std::path::PathBuf::from("src/alpha.rs")],
            file_paths: vec![std::path::PathBuf::from("src/alpha.rs")],
        };
        // 空 notes:不含引导节
        let user = wiki_page_user_prompt(&chunk, "卡片摘要", &[]);
        assert!(!user.contains("项目引导说明"), "空 notes 不应生成引导节");
        // 非空 notes:含节标题与每条内容
        let notes = vec!["命名规范:公开函数必须写文档注释".to_string(), "必写小节:用法示例".to_string()];
        let user = wiki_page_user_prompt(&chunk, "卡片摘要", &notes);
        assert!(user.contains("## 项目引导说明"), "notes 非空应生成引导节: {}", user);
        assert!(user.contains("命名规范:公开函数必须写文档注释"), "应包含第一条 note");
        assert!(user.contains("必写小节:用法示例"), "应包含第二条 note");
    }

    /// 签名级片段注入(v32 7.1 FR-201):实体清单行必须携带签名(≤8 行/≤160
    /// 字符,超长截断加 …);签名缺失/空白 → 空串不输出占位。
    #[test]
    fn test_wiki_page_user_prompt_injects_entity_signature() {
        // 签名存在且短:完整输出
        let e = crate::ingest::parser::Entity {
            name: "short_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 2,
            doc_comment: None,
            signature: Some("pub fn short_fn(x: u32) -> u32".into()),
            visibility: None,
        };
        assert_eq!(
            entity_signature_line(&e),
            ",签名: pub fn short_fn(x: u32) -> u32"
        );
        // 超 8 行:截断至 160 字符加 …
        let long = (0..10).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
        let e2 = crate::ingest::parser::Entity {
            name: "long_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 12,
            doc_comment: None,
            signature: Some(long),
            visibility: None,
        };
        let out = entity_signature_line(&e2);
        assert!(out.starts_with(",签名: "));
        assert!(out.ends_with(''), "超限签名必须截断加 …: {out}");
        assert!(out.chars().count() <= 167, "截断后不超过 160+签名前缀: {out}");
        // 单行超 160 字符(>8 行分支之外的另一截断触发):截断加 …
        let wide = "w".repeat(200);
        let e_wide = crate::ingest::parser::Entity {
            name: "wide_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 2,
            doc_comment: None,
            signature: Some(wide),
            visibility: None,
        };
        let out_wide = entity_signature_line(&e_wide);
        assert!(out_wide.ends_with(''), "160 字符截断分支: {out_wide}");
        assert_eq!(out_wide.chars().count(), 166, "160 截断+前缀5+…1");
        // 阈值临界点(test_engineer 缺口):恰好 8 行不截断、恰好 9 行截断、
        // 恰好 160 字符不截断
        let e8 = crate::ingest::parser::Entity {
            name: "eight_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 9,
            doc_comment: None,
            signature: Some((0..8).map(|i| format!("l{i}")).collect::<Vec<_>>().join("\n")),
            visibility: None,
        };
        let out8 = entity_signature_line(&e8);
        assert!(!out8.ends_with(''), "恰好 8 行不截断: {out8}");
        let e9 = crate::ingest::parser::Entity {
            name: "nine_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 10,
            doc_comment: None,
            signature: Some((0..9).map(|i| format!("l{i}")).collect::<Vec<_>>().join("\n")),
            visibility: None,
        };
        let out9 = entity_signature_line(&e9);
        assert!(out9.ends_with(''), "恰好 9 行截断: {out9}");
        let e160 = crate::ingest::parser::Entity {
            name: "exact160_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 2,
            doc_comment: None,
            signature: Some("w".repeat(160)),
            visibility: None,
        };
        let out160 = entity_signature_line(&e160);
        assert!(!out160.ends_with(''), "恰好 160 字符不截断: {out160}");
        // CRLF 源文件(\r\n 换行):压平后不得残留 \r(reviewer LOW)
        let crlf = "pub fn a(\r\n    x: u32,\r\n) -> u32".to_string();
        let e_crlf = crate::ingest::parser::Entity {
            name: "crlf_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 4,
            doc_comment: None,
            signature: Some(crlf),
            visibility: None,
        };
        let out_crlf = entity_signature_line(&e_crlf);
        assert!(!out_crlf.contains('\r'), "CRLF 残留 \r: {out_crlf}");
        // 压平只把换行变成空格,行内缩进原样保留
        assert!(
            out_crlf.contains("pub fn a(     x: u32, ) -> u32"),
            "CRLF 压平: {out_crlf}"
        );
        // 签名缺失 / 空白:空串(不输出占位)
        let e3 = crate::ingest::parser::Entity {
            name: "no_sig".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 2,
            doc_comment: None,
            signature: None,
            visibility: None,
        };
        assert_eq!(entity_signature_line(&e3), "");
        let e4 = crate::ingest::parser::Entity {
            name: "blank_sig".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 2,
            doc_comment: None,
            signature: Some("   \n  ".into()),
            visibility: None,
        };
        assert_eq!(entity_signature_line(&e4), "");
        // 集成:wiki page prompt 携带签名行
        let mut chunk = make_test_chunk(&["src", "alpha"]);
        chunk.entities = vec![crate::ingest::parser::Entity {
            name: "alpha_fn".into(),
            kind: "fn".into(),
            line_start: 1,
            line_end: 3,
            doc_comment: None,
            signature: Some("pub fn alpha_fn()".into()),
            visibility: None,
        }];
        chunk.entity_sources = vec![std::path::PathBuf::from("src/alpha.rs")];
        let user = wiki_page_user_prompt(&chunk, "卡片摘要", &[]);
        assert!(
            user.contains("src/alpha.rs:1,签名: pub fn alpha_fn()"),
            "引用清单行必须含签名: {}",
            user
        );
    }
}