Skip to main content

code_repo_wiki/output/
lint.rs

1//! Wiki 产物健康检查(lint)
2//!
3//! 对已生成的 wiki 产物目录做静态检查,供 `code-repo-wiki lint` 命令与 CI 使用。
4//! 对齐 LLM Wiki 最佳实践(Karpathy 的 lint 健康检查、Econowiz 的孤儿页 lint):
5//!
6//! 1. **孤儿页**:没有任何其他页面链接指向的模块页(无人可达 = 可能过期/重复)
7//! 2. **断链**:页面内链接指向不存在的产物文件(复制 crossref 语义,但作用于磁盘产物)
8//! 3. **过时**:页面生成时间戳早于其源文件修改时间(源码已变但文档未更新)
9//! 4. **bad-citation**:正文 `path:line` 引用指向不存在的文件或行号越界(引用契约的静态复核)
10//!
11//! 4b. **bad-vctx**:正文 `[[vctx:path#L-a-L-b@hash8]]` 手工标记做 5 步哈希只读校验(vericontext 协议,人工文档护栏:t05 决议不引入生成契约,只识别并校验已有标记)
12//!
13//! 5. **entity-coverage**:页面声称的实体不在 api.md 权威清单(LLM 编造的第二道闸;api.md 的模块名(## 节标题)属已知名——合成页按模块名引用不是实体声称)
14//! 6. **bad-mermaid**:产物中的 mermaid fence 无法被 merman 解析(历史产物/人工编辑/增量遗留)
15//! 7. **stale-entity**:api.md 权威清单的实体在当前源码中不存在(文档引用了已删除/重命名的符号)
16//!
17//! 检查对象是**磁盘上的产物文件**(真实用户看到的东西),而非内存中的文档对象。
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22use crate::output::citation;
23
24/// 单条 lint 问题
25#[derive(Debug, Clone)]
26pub struct LintIssue {
27    /// 问题类别: orphan / broken / stale / bad-citation / bad-vctx / entity-coverage / bad-mermaid / stale-entity
28    pub kind: &'static str,
29    /// 问题文件相对路径(相对 output_dir)
30    pub path: String,
31    /// 问题描述
32    pub message: String,
33}
34
35/// 执行 lint 检查,返回所有发现的问题(无问题返回空列表)
36///
37/// `output_dir` 为产物根目录(config.output_dir()),
38/// `source_roots` 为源码扫描根列表(用于过时检查的源文件 mtime 对比)。
39///
40/// 各类检查各一个私有函数(B7:单函数承载单一职责,lint() 只做组合):
41/// orphan(孤儿页)、broken(断链)、stale(过时)、bad-citation(引用存在性)、
42/// bad-vctx(vctx 标记哈希)、entity-coverage(实体覆盖率)、bad-mermaid(Mermaid 语法)。
43pub fn lint(output_dir: &Path, source_roots: &[PathBuf]) -> Vec<LintIssue> {
44    let mut issues = Vec::new();
45    let wiki_root = output_dir.join("wiki");
46
47    // 源码实体表:stale-entity(实体名集合)与 bad-citation-overlap(行区间表)
48    // 共用一次扫描(两检查的输入同源,各自消费不同投影)
49    let (source_entity_ranges, source_entity_names) = collect_source_entities(source_roots);
50
51    // 收集主语言目录下的全部 .md 产物(wiki 页 + 全局文档)
52    let languages = collect_language_dirs(&wiki_root);
53    for lang in &languages {
54        let lang_dir = wiki_root.join(lang);
55        let pages = collect_md_files(&lang_dir);
56
57        // 链接统计范围 = 语言目录页面 + 产物根目录页(_toc.md 在 wiki 根而非
58        // lang 目录,但其链接指向全部页面——不统计则每页都因无入链被误标孤儿)
59        let mut link_sources: Vec<PathBuf> = pages.clone();
60        let toc_path = output_dir.join("_toc.md");
61        if toc_path.exists() {
62            link_sources.push(toc_path);
63        }
64
65        issues.extend(check_orphan_pages(&pages, &link_sources, lang));
66        issues.extend(check_broken_links(&pages, lang));
67        issues.extend(check_stale(&pages, &output_dir.join("cards").join(lang), source_roots, lang));
68        issues.extend(check_citations(&pages, output_dir, source_roots, lang, &source_entity_ranges));
69        issues.extend(check_vctx_tokens(&pages, output_dir, source_roots, lang));
70        issues.extend(check_entity_coverage(&pages, &output_dir.join("wiki").join(lang).join("api.md"), lang, output_dir));
71        issues.extend(check_mermaid(&pages, lang));
72        issues.extend(check_stale_entities(
73            &output_dir.join("wiki").join(lang).join("api.md"),
74            lang,
75            output_dir,
76            &source_entity_names,
77        ));
78    }
79
80    issues
81}
82
83/// 1. 孤儿页检查:收集所有页面内链接(含目录页 _toc),统计入链,
84///    无任何页面链接指向的模块页报 orphan(全局文档由 TOC/概览引用,不算)
85fn check_orphan_pages(pages: &[PathBuf], link_sources: &[PathBuf], lang: &str) -> Vec<LintIssue> {
86    let mut incoming: HashMap<String, usize> = HashMap::new();
87    for page in link_sources {
88        // 页面读取失败(损坏/权限/竞态删除)时显式告警并跳过该页——
89        // 静默当作空内容会把页误报为孤儿/断链(失败必须可观测)
90        let Ok(content) = std::fs::read_to_string(page) else {
91            tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
92            continue;
93        };
94        for link in extract_md_links(&content) {
95            // 仅统计 wiki 页面间链接(.md 结尾且不含协议)
96            if link.ends_with(".md") && !link.contains("://") {
97                let stem = link
98                    .rsplit(['/', '\\'])
99                    .next()
100                    .unwrap_or(&link)
101                    .trim_end_matches(".md")
102                    .to_string();
103                *incoming.entry(stem).or_default() += 1;
104            }
105        }
106    }
107
108    let mut issues = Vec::new();
109    for page in pages {
110        let file_name = page
111            .file_name()
112            .map(|s| s.to_string_lossy().to_string())
113            .unwrap_or_default();
114        let stem = file_name.trim_end_matches(".md").to_string();
115        // 全局文档(api/overview/architecture/_toc/index)由 TOC/概览引用,不算孤儿
116        let is_global = matches!(
117            stem.as_str(),
118            "api" | "overview" | "architecture" | "_toc" | "index"
119        );
120        if !is_global && incoming.get(&stem).copied().unwrap_or(0) == 0 {
121            issues.push(LintIssue {
122                kind: "orphan",
123                path: format!("wiki/{lang}/{file_name}"),
124                message: format!("孤儿页: 无任何页面链接指向 {file_name}"),
125            });
126        }
127    }
128    issues
129}
130
131/// 2. 断链检查:页面内链接目标必须存在于产物文件集合
132fn check_broken_links(pages: &[PathBuf], lang: &str) -> Vec<LintIssue> {
133    let mut issues = Vec::new();
134    for page in pages {
135        // 页面读取失败(损坏/权限/竞态删除)时显式告警并跳过该页——
136        // 静默当作空内容会把页误报为孤儿/断链(失败必须可观测)
137        let Ok(content) = std::fs::read_to_string(page) else {
138            tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
139            continue;
140        };
141        let file_name = page
142            .file_name()
143            .map(|s| s.to_string_lossy().to_string())
144            .unwrap_or_default();
145        for link in extract_md_links(&content) {
146            if !link.ends_with(".md") || link.contains("://") {
147                continue;
148            }
149            // 解析链接目标:可能带 wiki/zh/ 前缀或纯文件名
150            let target_name = link.rsplit(['/', '\\']).next().unwrap_or(&link);
151            let target_exists = pages.iter().any(|p| {
152                p.file_name()
153                    .map(|s| s.to_string_lossy() == target_name)
154                    .unwrap_or(false)
155            });
156            if !target_exists {
157                issues.push(LintIssue {
158                    kind: "broken",
159                    path: format!("wiki/{lang}/{file_name}"),
160                    message: format!("断链: {link} 指向不存在的产物文件"),
161                });
162            }
163        }
164    }
165    issues
166}
167
168/// 3. 过时检查:模块页/卡片生成时间 < 其源文件 mtime
169///    (从产物内容提取源文件路径——相关文件段,与源码根下对应文件的 mtime 对比)
170fn check_stale(pages: &[PathBuf], cards_dir: &Path, source_roots: &[PathBuf], lang: &str) -> Vec<LintIssue> {
171    let mut stale_targets: Vec<PathBuf> = pages.to_vec();
172    stale_targets.extend(collect_md_files(cards_dir));
173
174    let mut issues = Vec::new();
175    for page in &stale_targets {
176        // 页面读取失败(损坏/权限/竞态删除)时显式告警并跳过该页——
177        // 静默当作空内容会把页误报为孤儿/断链(失败必须可观测)
178        let Ok(content) = std::fs::read_to_string(page) else {
179            tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
180            continue;
181        };
182        let file_name = page
183            .file_name()
184            .map(|s| s.to_string_lossy().to_string())
185            .unwrap_or_default();
186        let page_mtime = std::fs::metadata(page)
187            .and_then(|m| m.modified())
188            .ok();
189        let Some(page_time) = page_mtime else { continue };
190        for src in extract_source_files(&content) {
191            let abs = resolve_source_path(source_roots, &src);
192            if let Ok(meta) = std::fs::metadata(&abs)
193                && let Ok(src_time) = meta.modified()
194                && src_time > page_time
195            {
196                issues.push(LintIssue {
197                    kind: "stale",
198                    path: format!("wiki/{lang}/{file_name}"),
199                    message: format!(
200                        "过时: 源文件 {src} 的修改时间晚于页面生成时间(源码已变更,文档可能未更新)"
201                    ),
202                });
203            }
204        }
205    }
206    issues
207}
208
209/// 4. 引用存在性检查(P1-4 零成本评测):正文中的 `path:line` 引用必须可验证
210///    (生成层已校验-重试,此处对磁盘产物静态复核:引用文件存在且行号不越界)
211///    v14 B 组:叠加区间重叠判定(文件存在且行号有效但区间不覆盖任何实体 =
212///    行号对但内容错,bad-citation-overlap 新 kind;实体表无该文件键的引用
213///    放行——非代码文件引用合法)
214fn check_citations(
215    pages: &[PathBuf],
216    output_dir: &Path,
217    source_roots: &[PathBuf],
218    lang: &str,
219    entity_ranges: &std::collections::HashMap<String, Vec<(usize, usize)>>,
220) -> Vec<LintIssue> {
221    let mut issues = Vec::new();
222    for page in pages {
223        // 页面读取失败(损坏/权限/竞态删除)时显式告警并跳过该页——
224        // 静默当作空内容会把页误报为孤儿/断链(失败必须可观测)
225        let Ok(content) = std::fs::read_to_string(page) else {
226            tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
227            continue;
228        };
229        let file_name = page
230            .file_name()
231            .map(|s| s.to_string_lossy().to_string())
232            .unwrap_or_default();
233        for citation in citation::extract_citations(&content) {
234            // 路径越界段 `..` 拒绝(与生成层 citation.rs validate_citations
235            // 同一规则,v16 C 组对齐):`../src/x.rs` 可逃逸项目根、
236            // `src/../lib.rs` 可跳过目录层级——即使目标文件真实存在也按
237            // 无效处理。此前 lint 层未拒绝(不对称),手工/恶意页面可让
238            // lint 读取项目根外文件的元数据。
239            if citation.path.split(['/', '\\']).any(|seg| seg == "..") {
240                issues.push(LintIssue {
241                    kind: "bad-citation",
242                    path: format!("wiki/{lang}/{file_name}"),
243                    message: format!("路径含越界段 ..: `{}`", citation.path),
244                });
245                continue;
246            }
247            // 引用相对项目根:output_dir 的上级即项目根(AGENTS.md 生成同约定);
248            // source_roots 兜底逐根尝试(resolve_source_path 返回实际存在的路径)
249            let project_root = output_dir.parent().unwrap_or_else(|| Path::new("."));
250            let primary_abs = project_root.join(&citation.path);
251            let abs = if primary_abs.exists() {
252                primary_abs
253            } else {
254                resolve_source_path(source_roots, &citation.path)
255            };
256            let total_lines = std::fs::read_to_string(&abs)
257                .map(|s| s.lines().count())
258                .ok();
259            let Some(n) = total_lines else {
260                issues.push(LintIssue {
261                    kind: "bad-citation",
262                    path: format!("wiki/{lang}/{file_name}"),
263                    message: format!("引用不存在: `{}` 指向的文件找不到", citation.path),
264                });
265                continue;
266            };
267            if citation.end > n {
268                issues.push(LintIssue {
269                    kind: "bad-citation",
270                    path: format!("wiki/{lang}/{file_name}"),
271                    message: format!(
272                        "引用越界: `{}` 的 {}-{} 行超出文件总行数 {}",
273                        citation.path, citation.start, citation.end, n
274                    ),
275                });
276                continue;
277            }
278            // 区间重叠判定:实体表键 = citation_key(绝对路径、过滤 `./` 段、
279            // norm_sep 统一分隔符——与 collect_source_entities 的键同形态;
280            // 引用相对项目根解析出的绝对路径)。实体表无该文件键(非代码
281            // 文件)→ 放行。
282            let key = citation_key(&abs);
283            if let Some(ranges) = entity_ranges.get(&key)
284                && !citation::citation_overlaps_entity(&citation, ranges)
285            {
286                issues.push(LintIssue {
287                    kind: "bad-citation-overlap",
288                    path: format!("wiki/{lang}/{file_name}"),
289                    message: format!(
290                        "引用位置可疑: `{}` 的 {}-{} 行未覆盖该文件的任何实体(行号可能指向错误位置)",
291                        citation.path, citation.start, citation.end
292                    ),
293                });
294            }
295        }
296    }
297    issues
298}
299
300/// vctx 标记(vericontext 协议,t05 源码级核对):
301/// `[[vctx:path#L-<start>-L-<end>@<hash8>]]`,path 相对项目根、不含 `#`/`]`,
302/// start/end 为 1-based 包含行区间,hash8 为 SHA-256 前 8 位小写 hex。
303#[derive(Debug, Clone, PartialEq, Eq)]
304struct VctxToken {
305    path: String,
306    start: usize,
307    end: usize,
308    hash: String,
309}
310
311/// 从文本中扫描全部 `[[vctx:...]]` 标记并解析(手写扫描,与 citation.rs
312/// 同风格,项目无 regex 依赖)。返回逐条解析结果:Err 携带"格式不完整"
313/// 原因——`[[vctx:` 出现却无法完整解析 = 手写标记写坏,必须可观测。
314fn extract_vctx_tokens(content: &str) -> Vec<Result<VctxToken, String>> {
315    let mut out = Vec::new();
316    let mut rest = content;
317    while let Some(pos) = rest.find("[[vctx:") {
318        // token 尾部 = 第一个 "]]"(语法内 `]` 只出现在收尾,路径不含 `]`)
319        let after = &rest[pos + 7..];
320        let end = after.find("]]").map(|e| e + 2).unwrap_or(after.len());
321        out.push(parse_vctx_token(&after[..end]));
322        rest = &after[end..];
323    }
324    out
325}
326
327/// 单条 token 解析("[[vctx:" 前缀已剥除):`path#L-<start>-L-<end>@<hash8>]]`
328fn parse_vctx_token(s: &str) -> Result<VctxToken, String> {
329    let (path, rest) = s
330        .split_once('#')
331        .ok_or_else(|| "缺少 # 行区间段".to_string())?;
332    if path.is_empty() || path.contains(']') {
333        return Err("路径为空或含非法字符 ]".to_string());
334    }
335    let rest = rest
336        .strip_prefix("L-")
337        .ok_or_else(|| "行区间段应以 L- 开头".to_string())?;
338    let (start_str, rest) = rest
339        .split_once("-L-")
340        .ok_or_else(|| "行区间缺 -L- 分隔".to_string())?;
341    let start: usize = start_str
342        .parse()
343        .map_err(|_| "起始行号非数字".to_string())?;
344    let (end_str, rest) = rest
345        .split_once('@')
346        .ok_or_else(|| "缺 @ 哈希分隔".to_string())?;
347    let end: usize = end_str
348        .parse()
349        .map_err(|_| "结束行号非数字".to_string())?;
350    let hash = rest
351        .strip_suffix("]]")
352        .ok_or_else(|| "哈希段后缺 ]] 收尾".to_string())?;
353    if hash.len() != 8 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
354        return Err("哈希必须为 8 位十六进制".to_string());
355    }
356    Ok(VctxToken {
357        path: path.to_string(),
358        start,
359        end,
360        hash: hash.to_ascii_lowercase(),
361    })
362}
363
364/// vctx 5 步哈希(对齐 vericontext src/core/file.ts readCanonicalText +
365/// hashLineSpan,t05 Resolution 第 2 节源码级核对):
366/// 1. 严格 UTF-8 读取(read_to_string 失败即拒绝,不做字节替换);
367/// 2. EOL 归一化:`\r\n` 与裸 `\r` → `\n`(跨平台一致,vericontext 自评
368///    "最重要的可移植性决策";Rust lines() 等价处理 CRLF,但行哈希要求
369///    归一化后再取区间,故显式替换);
370/// 3. 取 [start, end] 行区间(1-based 包含);
371/// 4. 行间 join("\n") 且无尾换行;
372/// 5. SHA-256 hex 前 8 位小写(32 位截断够检测编辑,非安全边界)。
373fn vctx_line_hash(source: &str, start: usize, end: usize) -> String {
374    let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
375    let lines: Vec<&str> = normalized.split('\n').collect();
376    let span = lines[start - 1..end].join("\n");
377    use sha2::{Digest, Sha256};
378    let mut hasher = Sha256::new();
379    hasher.update(span.as_bytes());
380    hex::encode(hasher.finalize())[..8].to_string()
381}
382
383/// 4b. vctx 只读校验(v28 t06):产物中人工手写的 `[[vctx:path#L-a-L-b@hash8]]`
384/// 标记做 5 步哈希校验(vericontext 协议)。t05 决议:vctx 是"写时哈希协议"
385/// 而非生成契约,不要求 LLM 产出——本检查只兜住人工/工具写出的标记,与
386/// bad-citation(结构校验)互补:存在性+行区间是"行号对",哈希是"内容对"
387/// (防引用内容漂移)。
388fn check_vctx_tokens(
389    pages: &[PathBuf],
390    output_dir: &Path,
391    source_roots: &[PathBuf],
392    lang: &str,
393) -> Vec<LintIssue> {
394    let mut issues = Vec::new();
395    for page in pages {
396        // 页面读取失败(损坏/权限/竞态删除)时显式告警并跳过该页——
397        // 静默当作空内容会把页误报为孤儿/断链(失败必须可观测)
398        let Ok(content) = std::fs::read_to_string(page) else {
399            tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
400            continue;
401        };
402        let file_name = page
403            .file_name()
404            .map(|s| s.to_string_lossy().to_string())
405            .unwrap_or_default();
406        for token in extract_vctx_tokens(&content) {
407            let token = match token {
408                Ok(t) => t,
409                Err(reason) => {
410                    issues.push(LintIssue {
411                        kind: "bad-vctx",
412                        path: format!("wiki/{lang}/{file_name}"),
413                        message: format!("vctx 标记格式不完整: {reason}"),
414                    });
415                    continue;
416                }
417            };
418            // 路径越界段拒绝(与 check_citations 同一规则):`..` 段可逃逸
419            // 项目根,即使目标真实存在也按无效处理
420            if token.path.split(['/', '\\']).any(|seg| seg == "..") {
421                issues.push(LintIssue {
422                    kind: "bad-vctx",
423                    path: format!("wiki/{lang}/{file_name}"),
424                    message: format!("vctx 路径含越界段 ..: `{}`", token.path),
425                });
426                continue;
427            }
428            // 路径相对项目根解析(output_dir 的父目录),source_roots 兜底
429            let project_root = output_dir.parent().unwrap_or_else(|| Path::new("."));
430            let primary_abs = project_root.join(&token.path);
431            let abs = if primary_abs.exists() {
432                primary_abs
433            } else {
434                resolve_source_path(source_roots, &token.path)
435            };
436            // 严格 UTF-8 读取:失败 = 文件不存在或非 UTF-8(vericontext 同
437            // fail-closed 语义:file_missing / invalid_utf8 均拒绝)
438            let Ok(source) = std::fs::read_to_string(&abs) else {
439                issues.push(LintIssue {
440                    kind: "bad-vctx",
441                    path: format!("wiki/{lang}/{file_name}"),
442                    message: format!("vctx 目标不存在或非 UTF-8: `{}`", token.path),
443                });
444                continue;
445            };
446            let total = source.lines().count();
447            // 行区间有效性:1-based 包含区间,0 不是合法行号
448            if token.start == 0 || token.start > token.end || token.end > total {
449                issues.push(LintIssue {
450                    kind: "bad-vctx",
451                    path: format!("wiki/{lang}/{file_name}"),
452                    message: format!(
453                        "vctx 行区间越界: `{}` 的 {}-{} 行超出文件总行数 {}",
454                        token.path, token.start, token.end, total
455                    ),
456                });
457                continue;
458            }
459            let actual = vctx_line_hash(&source, token.start, token.end);
460            if actual != token.hash {
461                issues.push(LintIssue {
462                    kind: "bad-vctx",
463                    path: format!("wiki/{lang}/{file_name}"),
464                    message: format!(
465                        "vctx 哈希不匹配: `{}` 的 {}-{} 行内容已变更(现哈希 {actual},标记为 {})",
466                        token.path, token.start, token.end, token.hash
467                    ),
468                });
469            }
470        }
471    }
472    issues
473}
474
475/// 相对路径绝对化(实体表键的统一形态:相对 cwd 的路径与项目根解析的
476/// 绝对路径在 Windows 下必须同基准比较,否则反斜杠/正斜杠混存不命中)
477fn absolutize(p: &Path) -> PathBuf {
478    if p.is_absolute() {
479        p.to_path_buf()
480    } else {
481        std::env::current_dir().unwrap_or_default().join(p)
482    }
483}
484
485/// 实体表键的统一定型(v23 B 组):绝对化 + 过滤 `./` 段 + norm_sep。
486///
487/// include 通配符(`**/*.rs`)派生的源码根可能带 `./` 前缀(walk_files
488/// 逐级 join 保留该段),而引用侧从项目根解析的绝对路径无此段——两侧
489/// 键若不统一,实体表查询恒不命中,区间重叠检查静默失效(SA2 审计)。
490/// `..` 段保留:引用侧已在上游拒绝越界段(check_citations),本函数
491/// 只统一形态、不重复拦截。
492fn citation_key(p: &Path) -> String {
493    let mut cleaned = PathBuf::new();
494    for comp in absolutize(p).components() {
495        if matches!(comp, std::path::Component::CurDir) {
496            continue;
497        }
498        cleaned.push(comp);
499    }
500    crate::incremental::norm_sep(&cleaned.to_string_lossy())
501}
502
503/// 从 api.md 权威清单提取实体名集合(- ` 行 + entity_name_from_signature)
504///
505/// entity-coverage(页面声称实体须在清单中)与 stale-entity(清单实体须在
506/// 源码中)两侧共用同一提取,保证口径一致。
507fn api_known_entities(api_content: &str) -> std::collections::HashSet<String> {
508    api_content
509        .lines()
510        .filter(|l| l.trim_start().starts_with("- `"))
511        .filter_map(|l| {
512            // 签名如 `pub fn authenticate(username: &str) -> Option<User>`:
513            // 取第一个 '(' 前的最后标识符(跳过 pub/fn 等关键字前缀)
514            let inner = &l[l.find('`').unwrap() + 1..];
515            inner
516                .split('`')
517                .next()
518                .and_then(entity_name_from_signature)
519        })
520        .collect()
521}
522
523/// 从 api.md 提取模块名集合(`## ` 节标题 = 模块名,容器名而非叶子实体)。
524/// entity-coverage 声称侧命中模块名也属已知名:合成页(architecture.md 等)
525/// 按模块名引用模块(如 `src`、`src::storage`),不是叶子实体声称(P3 误报修复)
526fn api_module_names(api_content: &str) -> std::collections::HashSet<String> {
527    api_content
528        .lines()
529        .filter(|l| l.starts_with("## "))
530        .filter_map(|l| {
531            let name = l[3..].trim();
532            (!name.is_empty()).then(|| name.to_string())
533        })
534        .collect()
535}
536
537/// 5. 实体覆盖率检查(P1-4 零成本评测):模块页核心实体须存在于 api.md
538///    (api.md 由 graph 权威渲染,页面声称的实体若不在 = LLM 编造实体名,
539///    防幻觉第二道闸;api.md 仅主语言一份,只检查主语言目录)
540fn check_entity_coverage(pages: &[PathBuf], api_path: &Path, lang: &str, output_dir: &Path) -> Vec<LintIssue> {
541    if primary_language(output_dir) != *lang {
542        return Vec::new();
543    }
544    let Ok(api_content) = std::fs::read_to_string(api_path) else {
545        return Vec::new();
546    };
547    let known = api_known_entities(&api_content);
548    // 模块名(api.md 的 ## 节标题)也纳入已知名:LLM 合成页(architecture.md
549    // 等)会按模块名引用(如 `src`)。模块名是容器而非叶子实体,不在叶子
550    // 清单中——修复前一律误报 entity-coverage(P3 已知噪声)
551    let modules = api_module_names(&api_content);
552
553    let mut issues = Vec::new();
554    for page in pages {
555        // 页面读取失败(损坏/权限/竞态删除)时显式告警并跳过该页——
556        // 静默当作空内容会把页误报为孤儿/断链(失败必须可观测)
557        let Ok(content) = std::fs::read_to_string(page) else {
558            tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
559            continue;
560        };
561        let file_name = page
562            .file_name()
563            .map(|s| s.to_string_lossy().to_string())
564            .unwrap_or_default();
565        for entity in extract_entity_names(&content, &modules) {
566            // 声称命中叶子实体清单或模块名(容器名)即非编造;其余报错
567            if known.contains(&entity) || modules.contains(&entity) {
568                continue;
569            }
570            issues.push(LintIssue {
571                kind: "entity-coverage",
572                path: format!("wiki/{lang}/{file_name}"),
573                message: format!("实体覆盖率: 页面声称的实体 `{entity}` 不在 api.md 清单中(可能是编造或已删除)"),
574            });
575        }
576    }
577    issues
578}
579
580/// 扫描源码根并解析全部实体(stale-entity 与 bad-citation-overlap 共用一次
581/// 扫描,避免 lint 对源码做两遍 AST 解析)
582///
583/// 返回 (norm_sep 绝对路径 → 实体行区间列表, 全部实体名集合)。
584/// 解析失败的文件跳过(文件级损坏不是文档问题);源码根不存在/为空时
585/// 返回空表——调用方据此跳过对应检查(扫描失败 ≠ 文档过期/引用错误,
586/// 两种错误信号不能混淆)。
587fn collect_source_entities(
588    source_roots: &[PathBuf],
589) -> (
590    crate::output::citation::EntityRanges,
591    std::collections::HashSet<String>,
592) {
593    let mut ranges: crate::output::citation::EntityRanges =
594        std::collections::HashMap::new();
595    let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
596    let registry = crate::ingest::parser::ParserRegistry::new();
597    for root in source_roots {
598        if !root.is_dir() {
599            continue;
600        }
601        for entry in walk_files(root) {
602            let Some(processor) = registry.get_for_file(&entry) else { continue };
603            let Ok(source) = std::fs::read_to_string(&entry) else { continue };
604            if let Ok(insight) = processor.parse(&source, &entry) {
605                let key = citation_key(&entry);
606                ranges.insert(
607                    key,
608                    insight
609                        .entities
610                        .iter()
611                        .map(|e| (e.line_start, e.line_end))
612                        .collect(),
613                );
614                for entity in &insight.entities {
615                    names.insert(entity.name.clone());
616                }
617            }
618        }
619    }
620    (ranges, names)
621}
622
623/// 7. 符号漂移检查(v13 D1,N1):api.md 权威清单中的实体在当前源码中不存在
624///    → "文档引用了已删除实体"(entity-coverage 的反向:前者防 LLM 编造,
625///    本检查防文档过期——增量更新未覆盖、模块重构改名、人工删改产物)。
626///    零 LLM,源码侧直接 AST 解析(与生成侧同一 parser,口径一致)。
627fn check_stale_entities(
628    api_path: &Path,
629    lang: &str,
630    output_dir: &Path,
631    source_entity_names: &std::collections::HashSet<String>,
632) -> Vec<LintIssue> {
633    if primary_language(output_dir) != *lang {
634        return Vec::new();
635    }
636    let Ok(api_content) = std::fs::read_to_string(api_path) else {
637        return Vec::new();
638    };
639    let known = api_known_entities(&api_content);
640    if known.is_empty() {
641        return Vec::new();
642    }
643    if source_entity_names.is_empty() {
644        // 源码根为空/全解析失败时无从对比,跳过(避免把"扫描失败"误报成
645        // "文档过期"——二者错误信号不同,不能混淆)
646        return Vec::new();
647    }
648
649    let mut issues = Vec::new();
650    let mut stale: Vec<&String> = known
651        .iter()
652        .filter(|e| !source_entity_names.contains(*e))
653        .collect();
654    stale.sort();
655    for entity in stale {
656        issues.push(LintIssue {
657            kind: "stale-entity",
658            path: format!("wiki/{lang}/api.md"),
659            message: format!("符号漂移: api.md 中的实体 `{entity}` 在当前源码中不存在(已删除或重命名,文档过期)"),
660        });
661    }
662    issues
663}
664
665/// 递归收集目录下全部文件(跟随子目录,忽略隐藏目录与符号链接循环——
666/// 生产仓库正常布局下深度有限,不引入额外依赖)
667fn walk_files(dir: &Path) -> Vec<PathBuf> {
668    let mut out = Vec::new();
669    let Ok(entries) = std::fs::read_dir(dir) else { return out };
670    for entry in entries.flatten() {
671        let path = entry.path();
672        if path.is_dir() {
673            out.extend(walk_files(&path));
674        } else {
675            out.push(path);
676        }
677    }
678    out
679}
680
681/// 6. Mermaid 语法检查(G2):产物中的 mermaid fence 必须可被 merman 权威解析器解析
682///    (生成层已做校验-重试-降级,此处兜住历史产物/人工编辑/增量遗留三类来源;
683///    发现坏图即报 issue,CI 门禁语义与 bad-citation 一致:只阻断不自动修复)
684fn check_mermaid(pages: &[PathBuf], lang: &str) -> Vec<LintIssue> {
685    let mut issues = Vec::new();
686    for page in pages {
687        // 页面读取失败(损坏/权限/竞态删除)时显式告警并跳过该页——
688        // 静默当作空内容会把页误报为孤儿/断链(失败必须可观测)
689        let Ok(content) = std::fs::read_to_string(page) else {
690            tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
691            continue;
692        };
693        let file_name = page
694            .file_name()
695            .map(|s| s.to_string_lossy().to_string())
696            .unwrap_or_default();
697        for issue in crate::output::mermaid_check::validate_mermaid_blocks(&content) {
698            issues.push(LintIssue {
699                kind: "bad-mermaid",
700                path: format!("wiki/{lang}/{file_name}"),
701                message: format!(
702                    "Mermaid 校验失败(第 {} 个块): {}",
703                    issue.block_index + 1,
704                    issue.message
705                ),
706            });
707        }
708    }
709    issues
710}
711
712/// 主语言目录名:api.md 只写主语言一份(render_all 规则),实体覆盖检查以它为权威
713fn primary_language(output_dir: &Path) -> String {
714    // 遍历 wiki/ 下的语言目录,取含 api.md 的那个(主语言);无则返回空串(跳过检查)
715    let wiki_root = output_dir.join("wiki");
716    if let Ok(entries) = std::fs::read_dir(&wiki_root) {
717        for entry in entries.flatten() {
718            if entry.path().is_dir()
719                && entry.path().join("api.md").is_file()
720                && let Some(name) = entry.file_name().to_str()
721            {
722                return name.to_string();
723            }
724        }
725    }
726    String::new()
727}
728
729/// 从签名/实体文本中提取实体真名
730///
731/// 签名形态:`pub fn authenticate(username: &str) -> Option<User>`(函数)、
732/// `Foo`(struct/enum 裸名)、`def foo()`(Python)、`func Foo()`(Go)。
733/// 规则:有 '(' 时取第一个 '(' 前最后一个标识符(跳过 pub/fn/def 等
734/// 关键字前缀);无 '(' 时取最后一个标识符(裸名/类型)。页面侧与
735/// api.md 权威侧共用同一提取,保证两侧命名口径一致。
736pub fn entity_name_from_signature(sig: &str) -> Option<String> {
737    let trimmed = sig.trim();
738    if trimmed.is_empty() {
739        return None;
740    }
741    // 实体名前缀段 = 第一个 '(' 之前(函数/方法)或整段(类型声明)。
742    // v21 I 轮 Unity 抽样核证(20/20 真实存在):948 条 stale 中真实
743    // 只有 ~13 条,误报根因是最后标识符被三类后缀污染——逐类剥离:
744    // 0) 属性宏段(C# [ContextMenu("x")] / Rust #[test])必须在找 '('
745    //    之前切掉——属性自身的括号会先于函数括号被 find('(') 命中
746    let after_attr = match trimmed.rfind(']') {
747        Some(rb) => &trimmed[rb + 1..],
748        None => trimmed,
749    };
750    let mut head = match after_attr.find('(') {
751        Some(open) => &after_attr[..open],
752        None => after_attr,
753    };
754    // 1) 泛型约束子句(C# class Foo where T : class / Rust impl<T> Foo<T> where T: Clone):
755    //    其中的 ':' 会误导继承剥离,必须先切掉
756    if let Some(w) = head.find("where") {
757        head = &head[..w];
758    }
759    // 2) 继承/实现段(C# class Foo : Base, IBar / Java class Foo extends Bar 的 ':'):
760    //    基类名/接口名会污染最后标识符(实测 ScriptableObject/IDisposable 误报)
761    if let Some(colon) = head.find(':') {
762        head = &head[..colon];
763    }
764    // 3) 泛型参数列表(RegisterInstance<TService> / fn foo<T>):'<' 后是类型参数名
765    if let Some(lt) = head.find('<') {
766        head = &head[..lt];
767    }
768    let candidate = head
769        .split(|c: char| !c.is_alphanumeric() && c != '_')
770        .filter(|s| !s.is_empty())
771        .rfind(|_| true);
772    // v19 t03 过滤:单字符 token(LLM 文本噪声 a/_/P)与纯数字(42)
773    // 会污染 entity-coverage 统计并误报——api 权威侧与页面声称侧共用
774    // 本函数,两侧同口径不会误报。
775    candidate
776        .filter(|s| s.len() > 1 && !s.chars().all(|c| c.is_ascii_digit()))
777        .map(|s| s.to_string())
778}
779
780/// 提取 `- \`...\`` 声称行的反引号内文(非声称行返回 None)。
781/// extract_entity_names 借它做模块名原文精确匹配(多段名提取后会被截断),
782/// 不能只依赖 entity_name_from_signature 的提取结果
783fn claimed_backtick_inner(line: &str) -> Option<&str> {
784    line.trim()
785        .strip_prefix("- `")
786        .and_then(|rest| rest.find('`').map(|end| &rest[..end]))
787}
788
789/// 从模块页内容提取声称的实体名:`- `Name`` 核心实体行(反引号内实体真名)。
790/// `modules` 为 api.md 的模块名集合:原文精确命中模块名的声称行是模块引用
791/// (容器名,如 `src`、`src::storage`)而非实体声称,先行剔除——多段名
792/// `src::storage` 经 entity_name_from_signature(`::` 被当作继承段冒号)
793/// 会截断为 `src`,必须按原文剔除(P3 误报修复)
794fn extract_entity_names(content: &str, modules: &std::collections::HashSet<String>) -> Vec<String> {
795    let mut out = Vec::new();
796    for line in content.lines() {
797        let Some(inner) = claimed_backtick_inner(line) else { continue };
798        if modules.contains(inner) {
799            continue;
800        }
801        if let Some(name) = entity_name_from_signature(inner) {
802            out.push(name);
803        }
804    }
805    out
806}
807
808/// 收集 wiki 根下的语言目录(zh/en/...)
809fn collect_language_dirs(wiki_root: &Path) -> Vec<String> {
810    let mut langs = Vec::new();
811    if let Ok(entries) = std::fs::read_dir(wiki_root) {
812        for entry in entries.flatten() {
813            if entry.path().is_dir()
814                && let Some(name) = entry.file_name().to_str()
815            {
816                langs.push(name.to_string());
817            }
818        }
819    }
820    langs
821}
822
823/// 递归收集目录下所有 .md 文件
824fn collect_md_files(dir: &Path) -> Vec<PathBuf> {
825    let mut out = Vec::new();
826    let Ok(entries) = std::fs::read_dir(dir) else {
827        return out;
828    };
829    for entry in entries.flatten() {
830        let p = entry.path();
831        if p.is_dir() {
832            out.extend(collect_md_files(&p));
833        } else if p.extension().is_some_and(|e| e == "md") {
834            out.push(p);
835        }
836    }
837    out
838}
839
840/// 提取 markdown 文本中的链接目标 [text](target)
841fn extract_md_links(content: &str) -> Vec<String> {
842    let mut out = Vec::new();
843    let mut rest = content;
844    while let Some(start) = rest.find("](") {
845        let after = &rest[start + 2..];
846        let end = after.find(')').unwrap_or(after.len());
847        let target = after[..end].trim().to_string();
848        if !target.is_empty() {
849            out.push(target);
850        }
851        rest = &after[end.min(after.len())..];
852    }
853    out
854}
855
856/// 从卡片/页面内容提取源文件路径(`- `code`` 相关文件段)
857fn extract_source_files(content: &str) -> Vec<String> {
858    let mut out = Vec::new();
859    for line in content.lines() {
860        let line = line.trim();
861        if line.starts_with("- `") && line.ends_with('`') {
862            let inner = &line[3..line.len() - 1];
863            if inner.contains('.') && !inner.contains("://") {
864                out.push(inner.to_string());
865            }
866        }
867    }
868    out
869}
870
871/// 将产物中记录的源路径解析为绝对路径(相对源码根逐根尝试)
872fn resolve_source_path(source_roots: &[PathBuf], src: &str) -> PathBuf {
873    let p = Path::new(src);
874    if p.is_absolute() {
875        return p.to_path_buf();
876    }
877    for root in source_roots {
878        // 产物内路径是相对 cwd 的完整相对路径(如 "src/lib.rs"),可能已含 root 前缀:
879        // 先试 cwd 相对(p 原样),再试 root.join(p)(历史行为,兼容不含前缀的情况)
880        let p_path = Path::new(p);
881        if p_path.exists() {
882            return p_path.to_path_buf();
883        }
884        let candidate = root.join(p);
885        if candidate.exists() {
886            return candidate;
887        }
888    }
889    // 全部未命中:返回 cwd 相对路径(供 metadata 报错)
890    Path::new(p).to_path_buf()
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    /// 构造临时产物目录:两页面,a 链接 b(故 b 有入链、a 无入链=孤儿),
898    /// 且 a 链接不存在的 c.md(断链)
899    /// 构造临时产物目录(tag 区分并行测试,避免同 pid 目录互删):
900    /// 两页面,a 链接 b(故 b 有入链、a 无入链=孤儿),
901    /// 且 a 链接不存在的 c.md(断链)
902    fn make_fixture(tag: &str) -> (std::path::PathBuf, Vec<PathBuf>) {
903        let dir = std::env::temp_dir().join(format!(
904            "code_repo_wiki_lint_{}_{}",
905            tag,
906            std::process::id()
907        ));
908        let _ = std::fs::remove_dir_all(&dir);
909        let wiki = dir.join("wiki").join("zh");
910        std::fs::create_dir_all(&wiki).unwrap();
911        // a.md 链接到 b.md(入链)与不存在的 c.md(断链)
912        std::fs::write(
913            wiki.join("a.md"),
914            "# A\n\n- [B](wiki/zh/b.md)\n- [C](wiki/zh/c.md)\n",
915        )
916        .unwrap();
917        // 源文件先创建(b.md 引用其绝对路径,避免测试依赖 cwd——并行测试切换 cwd 会互相干扰)
918        let src_root = dir.join("src");
919        std::fs::create_dir_all(&src_root).unwrap();
920        let src_file = src_root.join("lib.rs");
921        std::fs::write(&src_file, "pub fn f() {}\n").unwrap();
922        let src_file_display = src_file.to_string_lossy().to_string();
923        // b.md 无任何链接,且引用源文件 src/lib.rs(绝对路径)
924        std::fs::write(
925            wiki.join("b.md"),
926            format!("# B\n\n## 相关文件\n\n- `{}`\n", src_file_display),
927        )
928        .unwrap();
929        // 让 src/lib.rs 明显晚于 b.md(b.md 引用绝对路径,resolve 直接命中)
930        let now = std::time::SystemTime::now();
931        let _ = std::fs::File::options()
932            .write(true)
933            .open(&src_file)
934            .unwrap();
935        let _ = filetime_set(&src_file, now);
936        let _ = filetime_set(&wiki.join("b.md"), now - std::time::Duration::from_secs(3600));
937        (dir, vec![src_root])
938    }
939
940    /// 简化版 mtime 设置(避免引入 filetime 依赖)
941    fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
942        // Windows/Linux 通用:打开文件并写回一个字节触发 mtime 更新不可靠,
943        // 这里直接返回 Ok——过时检查依赖系统 mtime,单测构造时序不稳定,
944        // 因此过时检查的断言放宽为"不 panic + 断链/孤儿断言准确"
945        let _ = (path, time);
946        Ok(())
947    }
948
949    #[test]
950    fn test_lint_orphan_and_broken() {
951        let (dir, src_roots) = make_fixture("orphan");
952        eprintln!("DEBUG orphan dir: {:?}", dir);
953        let issues = lint(&dir, &src_roots);
954        // 孤儿页: a.md 链接 b 但无任何入链 → 应命中
955        assert!(
956            issues.iter().any(|i| i.kind == "orphan" && i.path.ends_with("a.md")),
957            "a.md 无入链应为孤儿, 实际: {:?}",
958            issues
959        );
960        // b.md 被 a 链接 → 不应是孤儿
961        assert!(
962            !issues.iter().any(|i| i.kind == "orphan" && i.path.ends_with("b.md")),
963            "b.md 有入链不应是孤儿, 实际: {:?}",
964            issues
965        );
966        // 断链: a.md 指向 c.md 不存在
967        assert!(
968            issues.iter().any(|i| i.kind == "broken" && i.message.contains("c.md")),
969            "a.md → c.md 应为断链, 实际: {:?}",
970            issues
971        );
972        let _ = std::fs::remove_dir_all(&dir);
973    }
974
975    /// 过时检查:产物引用源文件且源文件 mtime 更新 → 报 stale。
976    /// 独立构造 fixture(不共享 make_fixture,避免并行测试竞态):
977    /// 页面引用源文件绝对路径,先写页面再写源文件(源严格更新),
978    /// 重写源文件刷新 mtime 后 lint 应报 stale。
979    #[test]
980    fn test_lint_stale_detects_newer_source() {
981        let dir = std::env::temp_dir().join(format!(
982            "code_repo_wiki_lint_stale_{}",
983            std::process::id()
984        ));
985        let _ = std::fs::remove_dir_all(&dir);
986        let wiki = dir.join("wiki").join("zh");
987        std::fs::create_dir_all(&wiki).unwrap();
988        let src_root = dir.join("src");
989        std::fs::create_dir_all(&src_root).unwrap();
990        let src_file = src_root.join("lib.rs");
991        std::fs::write(&src_file, "pub fn f() {}\n").unwrap();
992        let abs = src_file.to_string_lossy().to_string();
993        // 页面引用源文件绝对路径
994        std::fs::write(
995            wiki.join("lib.md"),
996            format!("# Lib\n\n## 相关文件\n\n- `{}`\n", abs),
997        )
998        .unwrap();
999        // 先等页面 mtime 落定,再重写源文件(严格更新)
1000        std::thread::sleep(std::time::Duration::from_millis(50));
1001        std::fs::write(&src_file, "pub fn updated() {}\n").unwrap();
1002        std::thread::sleep(std::time::Duration::from_millis(1100));
1003
1004        let issues = lint(&dir, &[src_root]);
1005        let _ = std::fs::remove_dir_all(&dir);
1006
1007        assert!(
1008            issues.iter().any(|i| i.kind == "stale"),
1009            "源文件更新后应报过时, 实际: {:?}",
1010            issues
1011        );
1012    }
1013
1014    /// v23 B 组防回归:include 通配(`**/*.rs`)派生的源码根带 `./` 段时,
1015    /// 区间重叠检查必须仍命中实体区间——修复前两侧键形态不一致(实体表
1016    /// 键含 `/./` 段、引用侧无),实体表查询恒空,检查静默失效。
1017    /// 同一 fixture 分别用 `./` 形态与常规形态的源码根,断言结果一致:
1018    /// 合法引用(落在实体区间)不误报,行号指向实体间隙的引用必报。
1019    #[test]
1020    fn test_lint_citation_overlap_survives_dot_slash_source_roots() {
1021        let dir = std::env::temp_dir().join(format!(
1022            "code_repo_wiki_lint_dotslash_{}",
1023            std::process::id()
1024        ));
1025        let _ = std::fs::remove_dir_all(&dir);
1026        let wiki = dir.join("wiki").join("zh");
1027        std::fs::create_dir_all(&wiki).unwrap();
1028        let src_root = dir.join("src");
1029        std::fs::create_dir_all(&src_root).unwrap();
1030        // 两个实体各占一行:f 在 1 行、g 在 2 行,第 3 行空白
1031        // (引用 3-3 落在文件内但不覆盖任何实体=间隙;若文件只有 2 行
1032        // 则 3-3 属越界,会在 overlap 判定前被 bad-citation 拦截)
1033        std::fs::write(src_root.join("lib.rs"), "pub fn f() {}\npub fn g() {}\n\n").unwrap();
1034        // 引用写相对项目根(= output_dir.parent())的路径。注意必须含父
1035        // 目录前缀(如 code_repo_wiki_lint_dotslash_<pid>/src/lib.rs)——若只写
1036        // src/lib.rs,resolve_source_path 的 cwd 相对兜底会命中本仓库自己
1037        // 的 src/lib.rs(cwd 恰好有同名文件),实体表键恒不命中
1038        let rel = format!(
1039            "{}/src/lib.rs",
1040            dir.file_name().unwrap().to_string_lossy()
1041        );
1042        // a.md 引用 1-1 行(f 的实体区间内)→ 合法,不应报 overlap
1043        std::fs::write(wiki.join("a.md"), format!("# A\n\n- 源: {rel}:1-1\n")).unwrap();
1044        // b.md 引用 3-3 行(无实体覆盖)→ 应报 overlap
1045        std::fs::write(wiki.join("b.md"), format!("# B\n\n- 源: {rel}:3-3\n")).unwrap();
1046        // 页面必须引用到源码(过时检查触发实体表构建),源码晚于页面
1047        let now = std::time::SystemTime::now();
1048        let _ = std::fs::File::options()
1049            .write(true)
1050            .open(src_root.join("lib.rs"))
1051            .unwrap();
1052        let _ = filetime_set(&src_root.join("lib.rs"), now);
1053        let _ = filetime_set(
1054            &wiki.join("a.md"),
1055            now - std::time::Duration::from_secs(3600),
1056        );
1057        let _ = filetime_set(
1058            &wiki.join("b.md"),
1059            now - std::time::Duration::from_secs(3600),
1060        );
1061        // `./` 段形态:join(".") 在 Windows 与 Unix 均保留 CurDir 段
1062        let dot_roots = vec![src_root.join(".").join("lib.rs").parent().unwrap().to_path_buf()];
1063        let plain_roots = vec![src_root.clone()];
1064        for (tag, roots) in [("dot", dot_roots), ("plain", plain_roots)] {
1065            let issues = lint(&dir, &roots);
1066            assert!(
1067                !issues
1068                    .iter()
1069                    .any(|i| i.kind == "bad-citation-overlap" && i.path.ends_with("a.md")),
1070                "[{tag}] 引用实体区间内不应报 overlap, 实际: {:?}",
1071                issues
1072            );
1073            assert!(
1074                issues
1075                    .iter()
1076                    .any(|i| i.kind == "bad-citation-overlap" && i.path.ends_with("b.md")),
1077                "[{tag}] 引用实体间隙应报 overlap, 实际: {:?}",
1078                issues
1079            );
1080        }
1081        let _ = std::fs::remove_dir_all(&dir);
1082    }
1083
1084    #[test]
1085    fn test_extract_md_links() {
1086        let links = extract_md_links("- [B](wiki/zh/b.md) 和 [外部](https://x.com/a.md)");
1087        assert!(links.contains(&"wiki/zh/b.md".to_string()));
1088        assert!(links.contains(&"https://x.com/a.md".to_string()));
1089        assert_eq!(links.len(), 2);
1090    }
1091
1092    #[test]
1093    fn test_extract_source_files() {
1094        let files = extract_source_files("## 相关文件\n\n- `src/lib.rs`\n- `tests/a.rs`\n");
1095        assert_eq!(files, vec!["src/lib.rs".to_string(), "tests/a.rs".to_string()]);
1096        // 链接行不应误提取
1097        assert!(extract_source_files("- [x](wiki/zh/a.md)").is_empty());
1098    }
1099
1100    #[test]
1101    fn test_lint_empty_dir() {
1102        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_empty_{}", std::process::id()));
1103        let _ = std::fs::remove_dir_all(&dir);
1104        std::fs::create_dir_all(dir.join("wiki").join("zh")).unwrap();
1105        let issues = lint(&dir, &[]);
1106        assert!(issues.is_empty(), "空目录应无问题, 实际: {:?}", issues);
1107        let _ = std::fs::remove_dir_all(&dir);
1108    }
1109
1110    /// 语言目录缺失时 lint 不 panic
1111    #[test]
1112    fn test_lint_no_wiki_dir() {
1113        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_nodir_{}", std::process::id()));
1114        let _ = std::fs::remove_dir_all(&dir);
1115        let issues = lint(&dir, &[]);
1116        assert!(issues.is_empty());
1117        let _ = std::fs::remove_dir_all(&dir);
1118    }
1119
1120    /// P1-4 引用存在性:产物中的 `path:line` 引用指向不存在的文件 → bad-citation
1121    #[test]
1122    fn test_lint_bad_citation_missing_file() {
1123        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cite_{}", std::process::id()));
1124        let _ = std::fs::remove_dir_all(&dir);
1125        let out = dir.join(".code-repo-wiki"); // output_dir 的父目录 = 项目根
1126        let wiki = out.join("wiki").join("zh");
1127        std::fs::create_dir_all(&wiki).unwrap();
1128        // 页面引用不存在的文件
1129        std::fs::write(
1130            wiki.join("m.md"),
1131            "# M\n\n核心逻辑见 `src/ghost.rs:10`\n",
1132        )
1133        .unwrap();
1134        std::fs::create_dir_all(dir.join("src")).unwrap();
1135
1136        let issues = lint(&out, &[]);
1137        assert!(
1138            issues.iter().any(|i| i.kind == "bad-citation"),
1139            "引用不存在的文件应报 bad-citation, 实际: {:?}",
1140            issues
1141        );
1142        let _ = std::fs::remove_dir_all(&dir);
1143    }
1144
1145    /// P1-4 引用存在性:引用真实存在的文件且行号合法 → 无 bad-citation
1146    #[test]
1147    fn test_lint_bad_citation_valid_passes() {
1148        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cite_ok_{}", std::process::id()));
1149        let _ = std::fs::remove_dir_all(&dir);
1150        let out = dir.join(".code-repo-wiki");
1151        let wiki = out.join("wiki").join("zh");
1152        std::fs::create_dir_all(&wiki).unwrap();
1153        std::fs::create_dir_all(dir.join("src")).unwrap();
1154        std::fs::write(dir.join("src").join("real.rs"), "line1\nline2\n").unwrap();
1155        // 页面引用真实文件(相对项目根路径,output_dir 父目录解析命中)
1156        std::fs::write(
1157            wiki.join("m.md"),
1158            "# M\n\n核心逻辑见 `src/real.rs:1`\n",
1159        )
1160        .unwrap();
1161
1162        let issues = lint(&out, &[]);
1163        assert!(
1164            !issues.iter().any(|i| i.kind == "bad-citation"),
1165            "有效引用不应报错, 实际: {:?}",
1166            issues
1167        );
1168        let _ = std::fs::remove_dir_all(&dir);
1169    }
1170
1171    /// v14 B 组:区间重叠复核——文件存在且行号有效但引用区间不覆盖任何
1172    /// 实体(行号对但内容错)→ bad-citation-overlap;覆盖实体 → 通过;
1173    /// 无实体文件(README)→ 放行
1174    #[test]
1175    fn test_lint_bad_citation_overlap_detects_wrong_location() {
1176        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_overlap_{}", std::process::id()));
1177        let _ = std::fs::remove_dir_all(&dir);
1178        let out = dir.join(".code-repo-wiki");
1179        let wiki = out.join("wiki").join("zh");
1180        std::fs::create_dir_all(&wiki).unwrap();
1181        let src_root = dir.join("src");
1182        std::fs::create_dir_all(&src_root).unwrap();
1183        // 10 行源码:实体区间 (2,2)(fn server 定义在第 2 行)
1184        let source = "line1\npub fn server() {}\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
1185        std::fs::write(src_root.join("server.rs"), source).unwrap();
1186        std::fs::write(dir.join("README.md"), "docs\n").unwrap();
1187        // 页面:引用 2 行(覆盖实体,合法)+ 引用 8 行(文件内但区间外)+ README(无实体)
1188        std::fs::write(
1189            wiki.join("m.md"),
1190            "# M\n\n- `src/server.rs:2` 核心\n- `src/server.rs:8` 位置可疑\n- `README.md:1` 说明\n",
1191        )
1192        .unwrap();
1193
1194        let issues = lint(&out, &[src_root]);
1195        let overlaps: Vec<_> = issues
1196            .iter()
1197            .filter(|i| i.kind == "bad-citation-overlap")
1198            .collect();
1199        assert_eq!(overlaps.len(), 1, "只应报区间外引用, 实际: {:?}", issues);
1200        assert!(
1201            overlaps[0].message.contains("src/server.rs") && overlaps[0].message.contains("8"),
1202            "应指向 8 行引用: {}",
1203            overlaps[0].message
1204        );
1205        assert!(
1206            !issues.iter().any(|i| i.kind == "bad-citation"),
1207            "文件级校验不应误报(文件存在且行号合法): {:?}",
1208            issues
1209        );
1210
1211        // 源码根为空:区间检查跳过(扫描失败 ≠ 引用错误)
1212        let empty_root = dir.join("empty_src");
1213        std::fs::create_dir_all(&empty_root).unwrap();
1214        let issues2 = lint(&out, &[empty_root]);
1215        assert!(
1216            !issues2.iter().any(|i| i.kind == "bad-citation-overlap"),
1217            "空源码根应跳过区间检查: {:?}",
1218            issues2
1219        );
1220
1221        let _ = std::fs::remove_dir_all(&dir);
1222    }
1223    #[test]
1224    fn test_lint_entity_coverage_detects_fake() {
1225        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cov_{}", std::process::id()));
1226        let _ = std::fs::remove_dir_all(&dir);
1227        let wiki = dir.join("wiki").join("zh");
1228        std::fs::create_dir_all(&wiki).unwrap();
1229        // api.md 权威清单只有 Foo
1230        std::fs::write(
1231            wiki.join("api.md"),
1232            "# API 参考\n\n## m\n\n- `Foo` — 描述 — m.rs:1\n",
1233        )
1234        .unwrap();
1235        // 模块页声称 FakeEntity(不在 api.md)
1236        std::fs::write(
1237            wiki.join("m.md"),
1238            "# M\n\n## 核心实体\n\n- `FakeEntity` — 编造的实体\n- `Foo` — 真实实体\n",
1239        )
1240        .unwrap();
1241
1242        let issues = lint(&dir, &[]);
1243        let cov: Vec<_> = issues.iter().filter(|i| i.kind == "entity-coverage").collect();
1244        assert_eq!(cov.len(), 1, "只应报编造实体, 实际: {:?}", issues);
1245        assert!(cov[0].message.contains("FakeEntity"), "应指向 FakeEntity: {}", cov[0].message);
1246        assert!(!cov[0].message.contains("Foo"), "真实实体不应误报");
1247        let _ = std::fs::remove_dir_all(&dir);
1248    }
1249
1250    /// P3 已知噪声防回归:合成页(architecture.md 等)引用模块名(api.md 的
1251    /// `## ` 节标题,如 `src`、`src::storage`)不应报 entity-coverage——
1252    /// 模块名是容器而非叶子实体,不在 api_known_entities 清单中;
1253    /// 编造的实体名仍必须报(防幻觉语义不变)
1254    #[test]
1255    fn test_lint_entity_coverage_accepts_module_names() {
1256        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cov_mod_{}", std::process::id()));
1257        let _ = std::fs::remove_dir_all(&dir);
1258        let wiki = dir.join("wiki").join("zh");
1259        std::fs::create_dir_all(&wiki).unwrap();
1260        // api.md:单段模块名 src 与多段模块名 src::storage 各一节 + 叶子实体
1261        std::fs::write(
1262            wiki.join("api.md"),
1263            "# API 参考\n\n## src\n\n- `Foo` — m.rs:1\n\n## src::storage\n\n- `SessionStore` — storage.rs:1\n",
1264        )
1265        .unwrap();
1266        // 合成页:按模块名引用(含多段名)+ 一个编造的实体名
1267        std::fs::write(
1268            wiki.join("architecture.md"),
1269            "# 架构\n\n## 模块\n\n- `src` — 核心模块\n- `src::storage` — 存储模块\n- `GhostEntity` — 编造的实体\n",
1270        )
1271        .unwrap();
1272
1273        let issues = lint(&dir, &[]);
1274        let cov: Vec<_> = issues.iter().filter(|i| i.kind == "entity-coverage").collect();
1275        assert_eq!(cov.len(), 1, "只应报编造实体, 实际: {:?}", issues);
1276        assert!(cov[0].message.contains("GhostEntity"), "应指向编造实体: {}", cov[0].message);
1277        assert!(
1278            !cov.iter().any(|i| i.message.contains("src")),
1279            "模块名引用不应误报: {:?}",
1280            cov
1281        );
1282        let _ = std::fs::remove_dir_all(&dir);
1283    }
1284
1285    #[test]
1286    fn test_extract_entity_names() {
1287        let content = "## 核心实体\n\n- `Server`(struct)— HTTP 服务\n- `fn connect()` — 连接\n- `foo_bar` — 下划线\n";
1288        let names = extract_entity_names(content, &std::collections::HashSet::new());
1289        assert!(names.contains(&"Server".to_string()));
1290        assert!(
1291            names.contains(&"connect".to_string()),
1292            "签名应提取实体真名(跳过 fn 关键字): {:?}",
1293            names
1294        );
1295        assert!(!names.contains(&"fn".to_string()), "关键字不应被提取: {:?}", names);
1296        assert!(names.contains(&"foo_bar".to_string()));
1297    }
1298
1299    /// v19 t03:单字符与纯数字 token 是 LLM 编造噪声(双仓库实测
1300    /// `P`/`_`/`a`/`2`),应被过滤以免污染 entity-coverage 声称侧;
1301    /// 多字符正常实体不受影响。
1302    #[test]
1303    fn test_entity_name_filters_noise_tokens() {
1304        assert_eq!(entity_name_from_signature("`P`"), None, "单字符应过滤");
1305        assert_eq!(entity_name_from_signature("`_`"), None, "下划线单字符应过滤");
1306        assert_eq!(entity_name_from_signature("`2`"), None, "纯数字应过滤");
1307        assert_eq!(entity_name_from_signature("fn x()"), None, "单字符函数名应过滤");
1308        let content = "## 核心实体\n\n- `Server`(struct)\n- `src` — 目录\n- `P` — 噪声\n- `2` — 数字\n";
1309        let names = extract_entity_names(content, &std::collections::HashSet::new());
1310        assert!(names.contains(&"Server".to_string()), "正常实体应保留: {:?}", names);
1311        assert!(names.contains(&"src".to_string()), "多字符实体应保留: {:?}", names);
1312        assert!(!names.contains(&"P".to_string()), "单字符噪声不应声称: {:?}", names);
1313        assert!(!names.contains(&"2".to_string()), "纯数字噪声不应声称: {:?}", names);
1314    }
1315
1316    /// v21 I 轮 Unity 抽样核证回归:三类后缀污染最后标识符,导致 stale
1317    /// 误报(948 条中真实仅 ~13 条)——基类/接口名、泛型参数名、属性宏名
1318    #[test]
1319    fn test_entity_name_strips_inheritance_generics_and_attributes() {
1320        // 继承/实现段:取类名而非基类/接口名
1321        assert_eq!(
1322            entity_name_from_signature("internal class PrimeTweenInstaller : ScriptableObject"),
1323            Some("PrimeTweenInstaller".into())
1324        );
1325        assert_eq!(
1326            entity_name_from_signature("public class Foo : Bar, IBaz"),
1327            Some("Foo".into())
1328        );
1329        // 泛型方法:取方法名而非类型参数名
1330        assert_eq!(
1331            entity_name_from_signature("public void RegisterInstance<TService>(TService instance)"),
1332            Some("RegisterInstance".into())
1333        );
1334        assert_eq!(
1335            entity_name_from_signature("pub fn load<T>(path: &str) -> T"),
1336            Some("load".into())
1337        );
1338        // 泛型约束子句中的 ':' 不误导继承剥离(C# where 子句)
1339        assert_eq!(
1340            entity_name_from_signature("class Foo<T> where T : class"),
1341            Some("Foo".into())
1342        );
1343        // 属性宏段:取 ']' 之后的实体名
1344        assert_eq!(
1345            entity_name_from_signature("[ContextMenu(\"x\")] public void DoThing()"),
1346            Some("DoThing".into())
1347        );
1348        // 普通签名不受影响
1349        assert_eq!(
1350            entity_name_from_signature("pub fn load(path: &str) -> Result<Config>"),
1351            Some("load".into())
1352        );
1353    }
1354
1355    /// G2:产物中的 mermaid fence 语法错误 → bad-mermaid;合法图不报
1356    #[test]
1357    fn test_lint_bad_mermaid_detects_broken_diagram() {
1358        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_mermaid_{}", std::process::id()));
1359        let _ = std::fs::remove_dir_all(&dir);
1360        let wiki = dir.join("wiki").join("zh");
1361        std::fs::create_dir_all(&wiki).unwrap();
1362        // 一个坏图页面 + 一个好图页面
1363        std::fs::write(
1364            wiki.join("bad.md"),
1365            "# Bad\n\n```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n",
1366        )
1367        .unwrap();
1368        std::fs::write(
1369            wiki.join("good.md"),
1370            "# Good\n\n```mermaid\nflowchart LR\nA[Start] --> B[End]\n```\n",
1371        )
1372        .unwrap();
1373
1374        let issues = lint(&dir, &[]);
1375        let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-mermaid").collect();
1376        assert_eq!(bad.len(), 1, "只有坏图应报 bad-mermaid, 实际: {:?}", issues);
1377        assert!(bad[0].path.ends_with("bad.md"), "应指向坏图页面: {}", bad[0].path);
1378        assert!(bad[0].message.contains("Unterminated"), "错误消息应可读: {}", bad[0].message);
1379        let _ = std::fs::remove_dir_all(&dir);
1380    }
1381
1382    /// D1(N1):符号漂移——api.md 权威清单中的实体在当前源码 AST 中不存在
1383    /// → 报 stale-entity(文档过期/实体已删除);源码中存在的实体不报。
1384    /// 源码根为空(扫描失败/无源码)时跳过检查,不把"扫描失败"误报成"文档过期"
1385    #[test]
1386    fn test_lint_stale_entity_detects_deleted_symbol() {
1387        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_stale_entity_{}", std::process::id()));
1388        let _ = std::fs::remove_dir_all(&dir);
1389        let wiki = dir.join("wiki").join("zh");
1390        std::fs::create_dir_all(&wiki).unwrap();
1391        let src_root = dir.join("src");
1392        std::fs::create_dir_all(&src_root).unwrap();
1393        // 源码只有 alpha(beta 已被删除/重命名)
1394        std::fs::write(src_root.join("lib.rs"), "pub fn alpha() {}\n").unwrap();
1395        // api.md 声明 alpha(存在)+ beta(已删除)
1396        std::fs::write(
1397            wiki.join("api.md"),
1398            "# API 参考\n\n## m\n\n- `alpha` — m.rs:1\n- `beta` — m.rs:2\n",
1399        )
1400        .unwrap();
1401
1402        let issues = lint(&dir, &[src_root]);
1403        let stale: Vec<_> = issues.iter().filter(|i| i.kind == "stale-entity").collect();
1404        assert_eq!(stale.len(), 1, "只应报已删除的 beta, 实际: {:?}", issues);
1405        assert!(stale[0].message.contains("beta"), "应指向 beta: {}", stale[0].message);
1406        assert!(!stale[0].message.contains("alpha"), "源码存在的实体不应误报");
1407
1408        // 源码根为空 → 跳过检查(扫描失败与文档过期是不同信号,不能混淆)
1409        let empty_root = dir.join("empty_src");
1410        std::fs::create_dir_all(&empty_root).unwrap();
1411        let issues2 = lint(&dir, &[empty_root]);
1412        assert!(
1413            !issues2.iter().any(|i| i.kind == "stale-entity"),
1414            "空源码根应跳过 stale-entity 检查, 实际: {:?}",
1415            issues2
1416        );
1417
1418        let _ = std::fs::remove_dir_all(&dir);
1419    }
1420
1421    /// v16 C 组:lint 层引用越界段拒绝(与生成层 citation.rs 对齐)——
1422    /// 页面引用 `../x.rs` 即使文件存在也报 bad-citation(越根读取防护)
1423    #[test]
1424    fn test_lint_bad_citation_rejects_dotdot() {
1425        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_dotdot_{}", std::process::id()));
1426        let _ = std::fs::remove_dir_all(&dir);
1427        let out = dir.join(".code-repo-wiki");
1428        let wiki = out.join("wiki").join("zh");
1429        std::fs::create_dir_all(&wiki).unwrap();
1430        // 项目根外确实存在该文件(证明"文件存在但路径越界"场景)
1431        std::fs::create_dir_all(dir.parent().unwrap().join("escape_dir")).unwrap();
1432        std::fs::write(
1433            dir.parent().unwrap().join("escape_dir").join("x.rs"),
1434            "line1\n",
1435        )
1436        .unwrap();
1437        std::fs::write(
1438            wiki.join("m.md"),
1439            "# M\n\n核心逻辑见 `../escape_dir/x.rs:1`\n",
1440        )
1441        .unwrap();
1442
1443        let issues = lint(&out, &[]);
1444        let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-citation").collect();
1445        assert_eq!(bad.len(), 1, "越界段应报 bad-citation, 实际: {:?}", issues);
1446        assert!(bad[0].message.contains("越界段 .."), "消息应说明越界: {}", bad[0].message);
1447
1448        let _ = std::fs::remove_dir_all(&dir);
1449    }
1450
1451    /// v28 t06:vctx 只读校验——合法标记(文件存在/行区间有效/哈希正确)不报错。
1452    /// 哈希期望值硬编码为独立算法的已知输出(SHA-256("hello") 前 8 位 =
1453    /// 2cf24dba),防实现自身偏差(自洽计算无法发现"两侧同错")。
1454    #[test]
1455    fn test_lint_vctx_valid_passes() {
1456        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_ok_{}", std::process::id()));
1457        let _ = std::fs::remove_dir_all(&dir);
1458        let out = dir.join(".code-repo-wiki");
1459        let wiki = out.join("wiki").join("zh");
1460        std::fs::create_dir_all(&wiki).unwrap();
1461        std::fs::create_dir_all(dir.join("src")).unwrap();
1462        std::fs::write(dir.join("src").join("real.rs"), "hello\n").unwrap();
1463        std::fs::write(
1464            wiki.join("m.md"),
1465            "# M\n\n核心逻辑见 [[vctx:src/real.rs#L-1-L-1@2cf24dba]]\n",
1466        )
1467        .unwrap();
1468
1469        let issues = lint(&out, &[]);
1470        assert!(
1471            !issues.iter().any(|i| i.kind == "bad-vctx"),
1472            "合法 vctx 标记不应报错, 实际: {:?}",
1473            issues
1474        );
1475        let _ = std::fs::remove_dir_all(&dir);
1476    }
1477
1478    /// v28 t06:路径不存在 → bad-vctx;顺带覆盖格式不完整标记
1479    /// (`[[vctx:src/real.rs]]` 缺 # 行区间段)也报 bad-vctx(手写护栏,
1480    /// 写坏的标记必须可观测)
1481    #[test]
1482    fn test_lint_vctx_missing_file_and_malformed() {
1483        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_miss_{}", std::process::id()));
1484        let _ = std::fs::remove_dir_all(&dir);
1485        let out = dir.join(".code-repo-wiki");
1486        let wiki = out.join("wiki").join("zh");
1487        std::fs::create_dir_all(&wiki).unwrap();
1488        std::fs::write(
1489            wiki.join("m.md"),
1490            "# M\n\n- [[vctx:src/ghost.rs#L-1-L-1@2cf24dba]]\n- [[vctx:src/real.rs]]\n",
1491        )
1492        .unwrap();
1493
1494        let issues = lint(&out, &[]);
1495        let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-vctx").collect();
1496        assert_eq!(bad.len(), 2, "缺失文件与格式不完整各报一条, 实际: {:?}", issues);
1497        assert!(
1498            bad.iter().any(|i| i.message.contains("ghost.rs")),
1499            "应指向缺失文件: {:?}",
1500            issues
1501        );
1502        assert!(
1503            bad.iter().any(|i| i.message.contains("格式不完整")),
1504            "应报格式不完整: {:?}",
1505            issues
1506        );
1507        let _ = std::fs::remove_dir_all(&dir);
1508    }
1509
1510    /// v28 t06:行区间越界(end > 文件总行数)→ bad-vctx
1511    #[test]
1512    fn test_lint_vctx_range_out_of_bounds() {
1513        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_range_{}", std::process::id()));
1514        let _ = std::fs::remove_dir_all(&dir);
1515        let out = dir.join(".code-repo-wiki");
1516        let wiki = out.join("wiki").join("zh");
1517        std::fs::create_dir_all(&wiki).unwrap();
1518        std::fs::create_dir_all(dir.join("src")).unwrap();
1519        std::fs::write(dir.join("src").join("small.rs"), "line1\nline2\n").unwrap();
1520        std::fs::write(
1521            wiki.join("m.md"),
1522            "# M\n\n见 [[vctx:src/small.rs#L-3-L-3@2cf24dba]]\n",
1523        )
1524        .unwrap();
1525
1526        let issues = lint(&out, &[]);
1527        let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-vctx").collect();
1528        assert_eq!(bad.len(), 1, "越界引用应报 bad-vctx, 实际: {:?}", issues);
1529        assert!(bad[0].message.contains("越界"), "消息应说明越界: {}", bad[0].message);
1530        let _ = std::fs::remove_dir_all(&dir);
1531    }
1532
1533    /// v28 t06:文件内容变更但标记保留旧哈希 → bad-vctx(哈希防内容漂移:
1534    /// 行号对、内容错也报警,补 bad-citation 结构校验之外的内容维度)
1535    #[test]
1536    fn test_lint_vctx_hash_mismatch() {
1537        let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_hash_{}", std::process::id()));
1538        let _ = std::fs::remove_dir_all(&dir);
1539        let out = dir.join(".code-repo-wiki");
1540        let wiki = out.join("wiki").join("zh");
1541        std::fs::create_dir_all(&wiki).unwrap();
1542        std::fs::create_dir_all(dir.join("src")).unwrap();
1543        // 先按原始内容计算标记哈希(模拟"标记写好后文件被改")
1544        let old_hash = vctx_line_hash("line1\nline2\n", 1, 2);
1545        std::fs::write(dir.join("src").join("lib.rs"), "changed\nline2\n").unwrap();
1546        std::fs::write(
1547            wiki.join("m.md"),
1548            format!("# M\n\n见 [[vctx:src/lib.rs#L-1-L-2@{old_hash}]]\n"),
1549        )
1550        .unwrap();
1551
1552        let issues = lint(&out, &[]);
1553        let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-vctx").collect();
1554        assert_eq!(bad.len(), 1, "内容变更后旧哈希应报错, 实际: {:?}", issues);
1555        assert!(bad[0].message.contains("哈希不匹配"), "消息应说明哈希不一致: {}", bad[0].message);
1556        let _ = std::fs::remove_dir_all(&dir);
1557    }
1558}