1use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22use crate::output::citation;
23
24#[derive(Debug, Clone)]
26pub struct LintIssue {
27 pub kind: &'static str,
29 pub path: String,
31 pub message: String,
33}
34
35pub 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 let (source_entity_ranges, source_entity_names) = collect_source_entities(source_roots);
50
51 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 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
83fn 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 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 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 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
131fn check_broken_links(pages: &[PathBuf], lang: &str) -> Vec<LintIssue> {
133 let mut issues = Vec::new();
134 for page in pages {
135 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 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
168fn 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 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
209fn 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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
304struct VctxToken {
305 path: String,
306 start: usize,
307 end: usize,
308 hash: String,
309}
310
311fn 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 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
327fn 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
364fn 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
383fn 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 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 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 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 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 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
475fn 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
485fn 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
503fn 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 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
523fn 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
537fn 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 let modules = api_module_names(&api_content);
552
553 let mut issues = Vec::new();
554 for page in pages {
555 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 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
580fn 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
623fn 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 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
665fn 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
681fn check_mermaid(pages: &[PathBuf], lang: &str) -> Vec<LintIssue> {
685 let mut issues = Vec::new();
686 for page in pages {
687 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
712fn primary_language(output_dir: &Path) -> String {
714 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
729pub fn entity_name_from_signature(sig: &str) -> Option<String> {
737 let trimmed = sig.trim();
738 if trimmed.is_empty() {
739 return None;
740 }
741 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 if let Some(w) = head.find("where") {
757 head = &head[..w];
758 }
759 if let Some(colon) = head.find(':') {
762 head = &head[..colon];
763 }
764 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 candidate
776 .filter(|s| s.len() > 1 && !s.chars().all(|c| c.is_ascii_digit()))
777 .map(|s| s.to_string())
778}
779
780fn 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
789fn 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
808fn 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
823fn 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
840fn 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
856fn 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
871fn 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 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 Path::new(p).to_path_buf()
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896
897 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 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 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 std::fs::write(
925 wiki.join("b.md"),
926 format!("# B\n\n## 相关文件\n\n- `{}`\n", src_file_display),
927 )
928 .unwrap();
929 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 fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
942 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 assert!(
956 issues.iter().any(|i| i.kind == "orphan" && i.path.ends_with("a.md")),
957 "a.md 无入链应为孤儿, 实际: {:?}",
958 issues
959 );
960 assert!(
962 !issues.iter().any(|i| i.kind == "orphan" && i.path.ends_with("b.md")),
963 "b.md 有入链不应是孤儿, 实际: {:?}",
964 issues
965 );
966 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 #[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 std::fs::write(
995 wiki.join("lib.md"),
996 format!("# Lib\n\n## 相关文件\n\n- `{}`\n", abs),
997 )
998 .unwrap();
999 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 #[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 std::fs::write(src_root.join("lib.rs"), "pub fn f() {}\npub fn g() {}\n\n").unwrap();
1034 let rel = format!(
1039 "{}/src/lib.rs",
1040 dir.file_name().unwrap().to_string_lossy()
1041 );
1042 std::fs::write(wiki.join("a.md"), format!("# A\n\n- 源: {rel}:1-1\n")).unwrap();
1044 std::fs::write(wiki.join("b.md"), format!("# B\n\n- 源: {rel}:3-3\n")).unwrap();
1046 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 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 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 #[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 #[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"); let wiki = out.join("wiki").join("zh");
1127 std::fs::create_dir_all(&wiki).unwrap();
1128 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 #[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 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 #[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 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 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 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 std::fs::write(
1231 wiki.join("api.md"),
1232 "# API 参考\n\n## m\n\n- `Foo` — 描述 — m.rs:1\n",
1233 )
1234 .unwrap();
1235 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 #[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 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 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 #[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 #[test]
1319 fn test_entity_name_strips_inheritance_generics_and_attributes() {
1320 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 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 assert_eq!(
1340 entity_name_from_signature("class Foo<T> where T : class"),
1341 Some("Foo".into())
1342 );
1343 assert_eq!(
1345 entity_name_from_signature("[ContextMenu(\"x\")] public void DoThing()"),
1346 Some("DoThing".into())
1347 );
1348 assert_eq!(
1350 entity_name_from_signature("pub fn load(path: &str) -> Result<Config>"),
1351 Some("load".into())
1352 );
1353 }
1354
1355 #[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 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 #[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 std::fs::write(src_root.join("lib.rs"), "pub fn alpha() {}\n").unwrap();
1395 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 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 #[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 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 #[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 #[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 #[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 #[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 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}