use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::output::citation;
#[derive(Debug, Clone)]
pub struct LintIssue {
pub kind: &'static str,
pub path: String,
pub message: String,
}
pub fn lint(output_dir: &Path, source_roots: &[PathBuf]) -> Vec<LintIssue> {
let mut issues = Vec::new();
let wiki_root = output_dir.join("wiki");
let (source_entity_ranges, source_entity_names) = collect_source_entities(source_roots);
let languages = collect_language_dirs(&wiki_root);
for lang in &languages {
let lang_dir = wiki_root.join(lang);
let pages = collect_md_files(&lang_dir);
let mut link_sources: Vec<PathBuf> = pages.clone();
let toc_path = output_dir.join("_toc.md");
if toc_path.exists() {
link_sources.push(toc_path);
}
issues.extend(check_orphan_pages(&pages, &link_sources, lang));
issues.extend(check_broken_links(&pages, lang));
issues.extend(check_stale(&pages, &output_dir.join("cards").join(lang), source_roots, lang));
issues.extend(check_citations(&pages, output_dir, source_roots, lang, &source_entity_ranges));
issues.extend(check_vctx_tokens(&pages, output_dir, source_roots, lang));
issues.extend(check_entity_coverage(&pages, &output_dir.join("wiki").join(lang).join("api.md"), lang, output_dir));
issues.extend(check_mermaid(&pages, lang));
issues.extend(check_stale_entities(
&output_dir.join("wiki").join(lang).join("api.md"),
lang,
output_dir,
&source_entity_names,
));
}
issues
}
fn check_orphan_pages(pages: &[PathBuf], link_sources: &[PathBuf], lang: &str) -> Vec<LintIssue> {
let mut incoming: HashMap<String, usize> = HashMap::new();
for page in link_sources {
let Ok(content) = std::fs::read_to_string(page) else {
tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
continue;
};
for link in extract_md_links(&content) {
if link.ends_with(".md") && !link.contains("://") {
let stem = link
.rsplit(['/', '\\'])
.next()
.unwrap_or(&link)
.trim_end_matches(".md")
.to_string();
*incoming.entry(stem).or_default() += 1;
}
}
}
let mut issues = Vec::new();
for page in pages {
let file_name = page
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let stem = file_name.trim_end_matches(".md").to_string();
let is_global = matches!(
stem.as_str(),
"api" | "overview" | "architecture" | "_toc" | "index"
);
if !is_global && incoming.get(&stem).copied().unwrap_or(0) == 0 {
issues.push(LintIssue {
kind: "orphan",
path: format!("wiki/{lang}/{file_name}"),
message: format!("孤儿页: 无任何页面链接指向 {file_name}"),
});
}
}
issues
}
fn check_broken_links(pages: &[PathBuf], lang: &str) -> Vec<LintIssue> {
let mut issues = Vec::new();
for page in pages {
let Ok(content) = std::fs::read_to_string(page) else {
tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
continue;
};
let file_name = page
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
for link in extract_md_links(&content) {
if !link.ends_with(".md") || link.contains("://") {
continue;
}
let target_name = link.rsplit(['/', '\\']).next().unwrap_or(&link);
let target_exists = pages.iter().any(|p| {
p.file_name()
.map(|s| s.to_string_lossy() == target_name)
.unwrap_or(false)
});
if !target_exists {
issues.push(LintIssue {
kind: "broken",
path: format!("wiki/{lang}/{file_name}"),
message: format!("断链: {link} 指向不存在的产物文件"),
});
}
}
}
issues
}
fn check_stale(pages: &[PathBuf], cards_dir: &Path, source_roots: &[PathBuf], lang: &str) -> Vec<LintIssue> {
let mut stale_targets: Vec<PathBuf> = pages.to_vec();
stale_targets.extend(collect_md_files(cards_dir));
let mut issues = Vec::new();
for page in &stale_targets {
let Ok(content) = std::fs::read_to_string(page) else {
tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
continue;
};
let file_name = page
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let page_mtime = std::fs::metadata(page)
.and_then(|m| m.modified())
.ok();
let Some(page_time) = page_mtime else { continue };
for src in extract_source_files(&content) {
let abs = resolve_source_path(source_roots, &src);
if let Ok(meta) = std::fs::metadata(&abs)
&& let Ok(src_time) = meta.modified()
&& src_time > page_time
{
issues.push(LintIssue {
kind: "stale",
path: format!("wiki/{lang}/{file_name}"),
message: format!(
"过时: 源文件 {src} 的修改时间晚于页面生成时间(源码已变更,文档可能未更新)"
),
});
}
}
}
issues
}
fn check_citations(
pages: &[PathBuf],
output_dir: &Path,
source_roots: &[PathBuf],
lang: &str,
entity_ranges: &std::collections::HashMap<String, Vec<(usize, usize)>>,
) -> Vec<LintIssue> {
let mut issues = Vec::new();
for page in pages {
let Ok(content) = std::fs::read_to_string(page) else {
tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
continue;
};
let file_name = page
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
for citation in citation::extract_citations(&content) {
if citation.path.split(['/', '\\']).any(|seg| seg == "..") {
issues.push(LintIssue {
kind: "bad-citation",
path: format!("wiki/{lang}/{file_name}"),
message: format!("路径含越界段 ..: `{}`", citation.path),
});
continue;
}
let project_root = output_dir.parent().unwrap_or_else(|| Path::new("."));
let primary_abs = project_root.join(&citation.path);
let abs = if primary_abs.exists() {
primary_abs
} else {
resolve_source_path(source_roots, &citation.path)
};
let total_lines = std::fs::read_to_string(&abs)
.map(|s| s.lines().count())
.ok();
let Some(n) = total_lines else {
issues.push(LintIssue {
kind: "bad-citation",
path: format!("wiki/{lang}/{file_name}"),
message: format!("引用不存在: `{}` 指向的文件找不到", citation.path),
});
continue;
};
if citation.end > n {
issues.push(LintIssue {
kind: "bad-citation",
path: format!("wiki/{lang}/{file_name}"),
message: format!(
"引用越界: `{}` 的 {}-{} 行超出文件总行数 {}",
citation.path, citation.start, citation.end, n
),
});
continue;
}
let key = citation_key(&abs);
if let Some(ranges) = entity_ranges.get(&key)
&& !citation::citation_overlaps_entity(&citation, ranges)
{
issues.push(LintIssue {
kind: "bad-citation-overlap",
path: format!("wiki/{lang}/{file_name}"),
message: format!(
"引用位置可疑: `{}` 的 {}-{} 行未覆盖该文件的任何实体(行号可能指向错误位置)",
citation.path, citation.start, citation.end
),
});
}
}
}
issues
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct VctxToken {
path: String,
start: usize,
end: usize,
hash: String,
}
fn extract_vctx_tokens(content: &str) -> Vec<Result<VctxToken, String>> {
let mut out = Vec::new();
let mut rest = content;
while let Some(pos) = rest.find("[[vctx:") {
let after = &rest[pos + 7..];
let end = after.find("]]").map(|e| e + 2).unwrap_or(after.len());
out.push(parse_vctx_token(&after[..end]));
rest = &after[end..];
}
out
}
fn parse_vctx_token(s: &str) -> Result<VctxToken, String> {
let (path, rest) = s
.split_once('#')
.ok_or_else(|| "缺少 # 行区间段".to_string())?;
if path.is_empty() || path.contains(']') {
return Err("路径为空或含非法字符 ]".to_string());
}
let rest = rest
.strip_prefix("L-")
.ok_or_else(|| "行区间段应以 L- 开头".to_string())?;
let (start_str, rest) = rest
.split_once("-L-")
.ok_or_else(|| "行区间缺 -L- 分隔".to_string())?;
let start: usize = start_str
.parse()
.map_err(|_| "起始行号非数字".to_string())?;
let (end_str, rest) = rest
.split_once('@')
.ok_or_else(|| "缺 @ 哈希分隔".to_string())?;
let end: usize = end_str
.parse()
.map_err(|_| "结束行号非数字".to_string())?;
let hash = rest
.strip_suffix("]]")
.ok_or_else(|| "哈希段后缺 ]] 收尾".to_string())?;
if hash.len() != 8 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("哈希必须为 8 位十六进制".to_string());
}
Ok(VctxToken {
path: path.to_string(),
start,
end,
hash: hash.to_ascii_lowercase(),
})
}
fn vctx_line_hash(source: &str, start: usize, end: usize) -> String {
let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
let span = lines[start - 1..end].join("\n");
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(span.as_bytes());
hex::encode(hasher.finalize())[..8].to_string()
}
fn check_vctx_tokens(
pages: &[PathBuf],
output_dir: &Path,
source_roots: &[PathBuf],
lang: &str,
) -> Vec<LintIssue> {
let mut issues = Vec::new();
for page in pages {
let Ok(content) = std::fs::read_to_string(page) else {
tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
continue;
};
let file_name = page
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
for token in extract_vctx_tokens(&content) {
let token = match token {
Ok(t) => t,
Err(reason) => {
issues.push(LintIssue {
kind: "bad-vctx",
path: format!("wiki/{lang}/{file_name}"),
message: format!("vctx 标记格式不完整: {reason}"),
});
continue;
}
};
if token.path.split(['/', '\\']).any(|seg| seg == "..") {
issues.push(LintIssue {
kind: "bad-vctx",
path: format!("wiki/{lang}/{file_name}"),
message: format!("vctx 路径含越界段 ..: `{}`", token.path),
});
continue;
}
let project_root = output_dir.parent().unwrap_or_else(|| Path::new("."));
let primary_abs = project_root.join(&token.path);
let abs = if primary_abs.exists() {
primary_abs
} else {
resolve_source_path(source_roots, &token.path)
};
let Ok(source) = std::fs::read_to_string(&abs) else {
issues.push(LintIssue {
kind: "bad-vctx",
path: format!("wiki/{lang}/{file_name}"),
message: format!("vctx 目标不存在或非 UTF-8: `{}`", token.path),
});
continue;
};
let total = source.lines().count();
if token.start == 0 || token.start > token.end || token.end > total {
issues.push(LintIssue {
kind: "bad-vctx",
path: format!("wiki/{lang}/{file_name}"),
message: format!(
"vctx 行区间越界: `{}` 的 {}-{} 行超出文件总行数 {}",
token.path, token.start, token.end, total
),
});
continue;
}
let actual = vctx_line_hash(&source, token.start, token.end);
if actual != token.hash {
issues.push(LintIssue {
kind: "bad-vctx",
path: format!("wiki/{lang}/{file_name}"),
message: format!(
"vctx 哈希不匹配: `{}` 的 {}-{} 行内容已变更(现哈希 {actual},标记为 {})",
token.path, token.start, token.end, token.hash
),
});
}
}
}
issues
}
fn absolutize(p: &Path) -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(p)
}
}
fn citation_key(p: &Path) -> String {
let mut cleaned = PathBuf::new();
for comp in absolutize(p).components() {
if matches!(comp, std::path::Component::CurDir) {
continue;
}
cleaned.push(comp);
}
crate::incremental::norm_sep(&cleaned.to_string_lossy())
}
fn api_known_entities(api_content: &str) -> std::collections::HashSet<String> {
api_content
.lines()
.filter(|l| l.trim_start().starts_with("- `"))
.filter_map(|l| {
let inner = &l[l.find('`').unwrap() + 1..];
inner
.split('`')
.next()
.and_then(entity_name_from_signature)
})
.collect()
}
fn api_module_names(api_content: &str) -> std::collections::HashSet<String> {
api_content
.lines()
.filter(|l| l.starts_with("## "))
.filter_map(|l| {
let name = l[3..].trim();
(!name.is_empty()).then(|| name.to_string())
})
.collect()
}
fn check_entity_coverage(pages: &[PathBuf], api_path: &Path, lang: &str, output_dir: &Path) -> Vec<LintIssue> {
if primary_language(output_dir) != *lang {
return Vec::new();
}
let Ok(api_content) = std::fs::read_to_string(api_path) else {
return Vec::new();
};
let known = api_known_entities(&api_content);
let modules = api_module_names(&api_content);
let mut issues = Vec::new();
for page in pages {
let Ok(content) = std::fs::read_to_string(page) else {
tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
continue;
};
let file_name = page
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
for entity in extract_entity_names(&content, &modules) {
if known.contains(&entity) || modules.contains(&entity) {
continue;
}
issues.push(LintIssue {
kind: "entity-coverage",
path: format!("wiki/{lang}/{file_name}"),
message: format!("实体覆盖率: 页面声称的实体 `{entity}` 不在 api.md 清单中(可能是编造或已删除)"),
});
}
}
issues
}
fn collect_source_entities(
source_roots: &[PathBuf],
) -> (
crate::output::citation::EntityRanges,
std::collections::HashSet<String>,
) {
let mut ranges: crate::output::citation::EntityRanges =
std::collections::HashMap::new();
let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
let registry = crate::ingest::parser::ParserRegistry::new();
for root in source_roots {
if !root.is_dir() {
continue;
}
for entry in walk_files(root) {
let Some(processor) = registry.get_for_file(&entry) else { continue };
let Ok(source) = std::fs::read_to_string(&entry) else { continue };
if let Ok(insight) = processor.parse(&source, &entry) {
let key = citation_key(&entry);
ranges.insert(
key,
insight
.entities
.iter()
.map(|e| (e.line_start, e.line_end))
.collect(),
);
for entity in &insight.entities {
names.insert(entity.name.clone());
}
}
}
}
(ranges, names)
}
fn check_stale_entities(
api_path: &Path,
lang: &str,
output_dir: &Path,
source_entity_names: &std::collections::HashSet<String>,
) -> Vec<LintIssue> {
if primary_language(output_dir) != *lang {
return Vec::new();
}
let Ok(api_content) = std::fs::read_to_string(api_path) else {
return Vec::new();
};
let known = api_known_entities(&api_content);
if known.is_empty() {
return Vec::new();
}
if source_entity_names.is_empty() {
return Vec::new();
}
let mut issues = Vec::new();
let mut stale: Vec<&String> = known
.iter()
.filter(|e| !source_entity_names.contains(*e))
.collect();
stale.sort();
for entity in stale {
issues.push(LintIssue {
kind: "stale-entity",
path: format!("wiki/{lang}/api.md"),
message: format!("符号漂移: api.md 中的实体 `{entity}` 在当前源码中不存在(已删除或重命名,文档过期)"),
});
}
issues
}
fn walk_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else { return out };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk_files(&path));
} else {
out.push(path);
}
}
out
}
fn check_mermaid(pages: &[PathBuf], lang: &str) -> Vec<LintIssue> {
let mut issues = Vec::new();
for page in pages {
let Ok(content) = std::fs::read_to_string(page) else {
tracing::warn!("lint 读取页面失败(跳过检查): {}", page.display());
continue;
};
let file_name = page
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
for issue in crate::output::mermaid_check::validate_mermaid_blocks(&content) {
issues.push(LintIssue {
kind: "bad-mermaid",
path: format!("wiki/{lang}/{file_name}"),
message: format!(
"Mermaid 校验失败(第 {} 个块): {}",
issue.block_index + 1,
issue.message
),
});
}
}
issues
}
fn primary_language(output_dir: &Path) -> String {
let wiki_root = output_dir.join("wiki");
if let Ok(entries) = std::fs::read_dir(&wiki_root) {
for entry in entries.flatten() {
if entry.path().is_dir()
&& entry.path().join("api.md").is_file()
&& let Some(name) = entry.file_name().to_str()
{
return name.to_string();
}
}
}
String::new()
}
pub fn entity_name_from_signature(sig: &str) -> Option<String> {
let trimmed = sig.trim();
if trimmed.is_empty() {
return None;
}
let after_attr = match trimmed.rfind(']') {
Some(rb) => &trimmed[rb + 1..],
None => trimmed,
};
let mut head = match after_attr.find('(') {
Some(open) => &after_attr[..open],
None => after_attr,
};
if let Some(w) = head.find("where") {
head = &head[..w];
}
if let Some(colon) = head.find(':') {
head = &head[..colon];
}
if let Some(lt) = head.find('<') {
head = &head[..lt];
}
let candidate = head
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|s| !s.is_empty())
.rfind(|_| true);
candidate
.filter(|s| s.len() > 1 && !s.chars().all(|c| c.is_ascii_digit()))
.map(|s| s.to_string())
}
fn claimed_backtick_inner(line: &str) -> Option<&str> {
line.trim()
.strip_prefix("- `")
.and_then(|rest| rest.find('`').map(|end| &rest[..end]))
}
fn extract_entity_names(content: &str, modules: &std::collections::HashSet<String>) -> Vec<String> {
let mut out = Vec::new();
for line in content.lines() {
let Some(inner) = claimed_backtick_inner(line) else { continue };
if modules.contains(inner) {
continue;
}
if let Some(name) = entity_name_from_signature(inner) {
out.push(name);
}
}
out
}
fn collect_language_dirs(wiki_root: &Path) -> Vec<String> {
let mut langs = Vec::new();
if let Ok(entries) = std::fs::read_dir(wiki_root) {
for entry in entries.flatten() {
if entry.path().is_dir()
&& let Some(name) = entry.file_name().to_str()
{
langs.push(name.to_string());
}
}
}
langs
}
fn collect_md_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
out.extend(collect_md_files(&p));
} else if p.extension().is_some_and(|e| e == "md") {
out.push(p);
}
}
out
}
fn extract_md_links(content: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = content;
while let Some(start) = rest.find("](") {
let after = &rest[start + 2..];
let end = after.find(')').unwrap_or(after.len());
let target = after[..end].trim().to_string();
if !target.is_empty() {
out.push(target);
}
rest = &after[end.min(after.len())..];
}
out
}
fn extract_source_files(content: &str) -> Vec<String> {
let mut out = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.starts_with("- `") && line.ends_with('`') {
let inner = &line[3..line.len() - 1];
if inner.contains('.') && !inner.contains("://") {
out.push(inner.to_string());
}
}
}
out
}
fn resolve_source_path(source_roots: &[PathBuf], src: &str) -> PathBuf {
let p = Path::new(src);
if p.is_absolute() {
return p.to_path_buf();
}
for root in source_roots {
let p_path = Path::new(p);
if p_path.exists() {
return p_path.to_path_buf();
}
let candidate = root.join(p);
if candidate.exists() {
return candidate;
}
}
Path::new(p).to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_fixture(tag: &str) -> (std::path::PathBuf, Vec<PathBuf>) {
let dir = std::env::temp_dir().join(format!(
"code_repo_wiki_lint_{}_{}",
tag,
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let wiki = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::write(
wiki.join("a.md"),
"# A\n\n- [B](wiki/zh/b.md)\n- [C](wiki/zh/c.md)\n",
)
.unwrap();
let src_root = dir.join("src");
std::fs::create_dir_all(&src_root).unwrap();
let src_file = src_root.join("lib.rs");
std::fs::write(&src_file, "pub fn f() {}\n").unwrap();
let src_file_display = src_file.to_string_lossy().to_string();
std::fs::write(
wiki.join("b.md"),
format!("# B\n\n## 相关文件\n\n- `{}`\n", src_file_display),
)
.unwrap();
let now = std::time::SystemTime::now();
let _ = std::fs::File::options()
.write(true)
.open(&src_file)
.unwrap();
let _ = filetime_set(&src_file, now);
let _ = filetime_set(&wiki.join("b.md"), now - std::time::Duration::from_secs(3600));
(dir, vec![src_root])
}
fn filetime_set(path: &Path, time: std::time::SystemTime) -> std::io::Result<()> {
let _ = (path, time);
Ok(())
}
#[test]
fn test_lint_orphan_and_broken() {
let (dir, src_roots) = make_fixture("orphan");
eprintln!("DEBUG orphan dir: {:?}", dir);
let issues = lint(&dir, &src_roots);
assert!(
issues.iter().any(|i| i.kind == "orphan" && i.path.ends_with("a.md")),
"a.md 无入链应为孤儿, 实际: {:?}",
issues
);
assert!(
!issues.iter().any(|i| i.kind == "orphan" && i.path.ends_with("b.md")),
"b.md 有入链不应是孤儿, 实际: {:?}",
issues
);
assert!(
issues.iter().any(|i| i.kind == "broken" && i.message.contains("c.md")),
"a.md → c.md 应为断链, 实际: {:?}",
issues
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_stale_detects_newer_source() {
let dir = std::env::temp_dir().join(format!(
"code_repo_wiki_lint_stale_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let wiki = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
let src_root = dir.join("src");
std::fs::create_dir_all(&src_root).unwrap();
let src_file = src_root.join("lib.rs");
std::fs::write(&src_file, "pub fn f() {}\n").unwrap();
let abs = src_file.to_string_lossy().to_string();
std::fs::write(
wiki.join("lib.md"),
format!("# Lib\n\n## 相关文件\n\n- `{}`\n", abs),
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(&src_file, "pub fn updated() {}\n").unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
let issues = lint(&dir, &[src_root]);
let _ = std::fs::remove_dir_all(&dir);
assert!(
issues.iter().any(|i| i.kind == "stale"),
"源文件更新后应报过时, 实际: {:?}",
issues
);
}
#[test]
fn test_lint_citation_overlap_survives_dot_slash_source_roots() {
let dir = std::env::temp_dir().join(format!(
"code_repo_wiki_lint_dotslash_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let wiki = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
let src_root = dir.join("src");
std::fs::create_dir_all(&src_root).unwrap();
std::fs::write(src_root.join("lib.rs"), "pub fn f() {}\npub fn g() {}\n\n").unwrap();
let rel = format!(
"{}/src/lib.rs",
dir.file_name().unwrap().to_string_lossy()
);
std::fs::write(wiki.join("a.md"), format!("# A\n\n- 源: {rel}:1-1\n")).unwrap();
std::fs::write(wiki.join("b.md"), format!("# B\n\n- 源: {rel}:3-3\n")).unwrap();
let now = std::time::SystemTime::now();
let _ = std::fs::File::options()
.write(true)
.open(src_root.join("lib.rs"))
.unwrap();
let _ = filetime_set(&src_root.join("lib.rs"), now);
let _ = filetime_set(
&wiki.join("a.md"),
now - std::time::Duration::from_secs(3600),
);
let _ = filetime_set(
&wiki.join("b.md"),
now - std::time::Duration::from_secs(3600),
);
let dot_roots = vec![src_root.join(".").join("lib.rs").parent().unwrap().to_path_buf()];
let plain_roots = vec![src_root.clone()];
for (tag, roots) in [("dot", dot_roots), ("plain", plain_roots)] {
let issues = lint(&dir, &roots);
assert!(
!issues
.iter()
.any(|i| i.kind == "bad-citation-overlap" && i.path.ends_with("a.md")),
"[{tag}] 引用实体区间内不应报 overlap, 实际: {:?}",
issues
);
assert!(
issues
.iter()
.any(|i| i.kind == "bad-citation-overlap" && i.path.ends_with("b.md")),
"[{tag}] 引用实体间隙应报 overlap, 实际: {:?}",
issues
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_extract_md_links() {
let links = extract_md_links("- [B](wiki/zh/b.md) 和 [外部](https://x.com/a.md)");
assert!(links.contains(&"wiki/zh/b.md".to_string()));
assert!(links.contains(&"https://x.com/a.md".to_string()));
assert_eq!(links.len(), 2);
}
#[test]
fn test_extract_source_files() {
let files = extract_source_files("## 相关文件\n\n- `src/lib.rs`\n- `tests/a.rs`\n");
assert_eq!(files, vec!["src/lib.rs".to_string(), "tests/a.rs".to_string()]);
assert!(extract_source_files("- [x](wiki/zh/a.md)").is_empty());
}
#[test]
fn test_lint_empty_dir() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_empty_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("wiki").join("zh")).unwrap();
let issues = lint(&dir, &[]);
assert!(issues.is_empty(), "空目录应无问题, 实际: {:?}", issues);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_no_wiki_dir() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_nodir_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let issues = lint(&dir, &[]);
assert!(issues.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_bad_citation_missing_file() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cite_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki"); let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n核心逻辑见 `src/ghost.rs:10`\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
let issues = lint(&out, &[]);
assert!(
issues.iter().any(|i| i.kind == "bad-citation"),
"引用不存在的文件应报 bad-citation, 实际: {:?}",
issues
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_bad_citation_valid_passes() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cite_ok_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki");
let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("real.rs"), "line1\nline2\n").unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n核心逻辑见 `src/real.rs:1`\n",
)
.unwrap();
let issues = lint(&out, &[]);
assert!(
!issues.iter().any(|i| i.kind == "bad-citation"),
"有效引用不应报错, 实际: {:?}",
issues
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_bad_citation_overlap_detects_wrong_location() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_overlap_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki");
let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
let src_root = dir.join("src");
std::fs::create_dir_all(&src_root).unwrap();
let source = "line1\npub fn server() {}\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
std::fs::write(src_root.join("server.rs"), source).unwrap();
std::fs::write(dir.join("README.md"), "docs\n").unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n- `src/server.rs:2` 核心\n- `src/server.rs:8` 位置可疑\n- `README.md:1` 说明\n",
)
.unwrap();
let issues = lint(&out, &[src_root]);
let overlaps: Vec<_> = issues
.iter()
.filter(|i| i.kind == "bad-citation-overlap")
.collect();
assert_eq!(overlaps.len(), 1, "只应报区间外引用, 实际: {:?}", issues);
assert!(
overlaps[0].message.contains("src/server.rs") && overlaps[0].message.contains("8"),
"应指向 8 行引用: {}",
overlaps[0].message
);
assert!(
!issues.iter().any(|i| i.kind == "bad-citation"),
"文件级校验不应误报(文件存在且行号合法): {:?}",
issues
);
let empty_root = dir.join("empty_src");
std::fs::create_dir_all(&empty_root).unwrap();
let issues2 = lint(&out, &[empty_root]);
assert!(
!issues2.iter().any(|i| i.kind == "bad-citation-overlap"),
"空源码根应跳过区间检查: {:?}",
issues2
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_entity_coverage_detects_fake() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cov_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let wiki = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::write(
wiki.join("api.md"),
"# API 参考\n\n## m\n\n- `Foo` — 描述 — m.rs:1\n",
)
.unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n## 核心实体\n\n- `FakeEntity` — 编造的实体\n- `Foo` — 真实实体\n",
)
.unwrap();
let issues = lint(&dir, &[]);
let cov: Vec<_> = issues.iter().filter(|i| i.kind == "entity-coverage").collect();
assert_eq!(cov.len(), 1, "只应报编造实体, 实际: {:?}", issues);
assert!(cov[0].message.contains("FakeEntity"), "应指向 FakeEntity: {}", cov[0].message);
assert!(!cov[0].message.contains("Foo"), "真实实体不应误报");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_entity_coverage_accepts_module_names() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_cov_mod_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let wiki = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::write(
wiki.join("api.md"),
"# API 参考\n\n## src\n\n- `Foo` — m.rs:1\n\n## src::storage\n\n- `SessionStore` — storage.rs:1\n",
)
.unwrap();
std::fs::write(
wiki.join("architecture.md"),
"# 架构\n\n## 模块\n\n- `src` — 核心模块\n- `src::storage` — 存储模块\n- `GhostEntity` — 编造的实体\n",
)
.unwrap();
let issues = lint(&dir, &[]);
let cov: Vec<_> = issues.iter().filter(|i| i.kind == "entity-coverage").collect();
assert_eq!(cov.len(), 1, "只应报编造实体, 实际: {:?}", issues);
assert!(cov[0].message.contains("GhostEntity"), "应指向编造实体: {}", cov[0].message);
assert!(
!cov.iter().any(|i| i.message.contains("src")),
"模块名引用不应误报: {:?}",
cov
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_extract_entity_names() {
let content = "## 核心实体\n\n- `Server`(struct)— HTTP 服务\n- `fn connect()` — 连接\n- `foo_bar` — 下划线\n";
let names = extract_entity_names(content, &std::collections::HashSet::new());
assert!(names.contains(&"Server".to_string()));
assert!(
names.contains(&"connect".to_string()),
"签名应提取实体真名(跳过 fn 关键字): {:?}",
names
);
assert!(!names.contains(&"fn".to_string()), "关键字不应被提取: {:?}", names);
assert!(names.contains(&"foo_bar".to_string()));
}
#[test]
fn test_entity_name_filters_noise_tokens() {
assert_eq!(entity_name_from_signature("`P`"), None, "单字符应过滤");
assert_eq!(entity_name_from_signature("`_`"), None, "下划线单字符应过滤");
assert_eq!(entity_name_from_signature("`2`"), None, "纯数字应过滤");
assert_eq!(entity_name_from_signature("fn x()"), None, "单字符函数名应过滤");
let content = "## 核心实体\n\n- `Server`(struct)\n- `src` — 目录\n- `P` — 噪声\n- `2` — 数字\n";
let names = extract_entity_names(content, &std::collections::HashSet::new());
assert!(names.contains(&"Server".to_string()), "正常实体应保留: {:?}", names);
assert!(names.contains(&"src".to_string()), "多字符实体应保留: {:?}", names);
assert!(!names.contains(&"P".to_string()), "单字符噪声不应声称: {:?}", names);
assert!(!names.contains(&"2".to_string()), "纯数字噪声不应声称: {:?}", names);
}
#[test]
fn test_entity_name_strips_inheritance_generics_and_attributes() {
assert_eq!(
entity_name_from_signature("internal class PrimeTweenInstaller : ScriptableObject"),
Some("PrimeTweenInstaller".into())
);
assert_eq!(
entity_name_from_signature("public class Foo : Bar, IBaz"),
Some("Foo".into())
);
assert_eq!(
entity_name_from_signature("public void RegisterInstance<TService>(TService instance)"),
Some("RegisterInstance".into())
);
assert_eq!(
entity_name_from_signature("pub fn load<T>(path: &str) -> T"),
Some("load".into())
);
assert_eq!(
entity_name_from_signature("class Foo<T> where T : class"),
Some("Foo".into())
);
assert_eq!(
entity_name_from_signature("[ContextMenu(\"x\")] public void DoThing()"),
Some("DoThing".into())
);
assert_eq!(
entity_name_from_signature("pub fn load(path: &str) -> Result<Config>"),
Some("load".into())
);
}
#[test]
fn test_lint_bad_mermaid_detects_broken_diagram() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_mermaid_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let wiki = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::write(
wiki.join("bad.md"),
"# Bad\n\n```mermaid\nflowchart LR\nA[hello world\nB --> C\n```\n",
)
.unwrap();
std::fs::write(
wiki.join("good.md"),
"# Good\n\n```mermaid\nflowchart LR\nA[Start] --> B[End]\n```\n",
)
.unwrap();
let issues = lint(&dir, &[]);
let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-mermaid").collect();
assert_eq!(bad.len(), 1, "只有坏图应报 bad-mermaid, 实际: {:?}", issues);
assert!(bad[0].path.ends_with("bad.md"), "应指向坏图页面: {}", bad[0].path);
assert!(bad[0].message.contains("Unterminated"), "错误消息应可读: {}", bad[0].message);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_stale_entity_detects_deleted_symbol() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_stale_entity_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let wiki = dir.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
let src_root = dir.join("src");
std::fs::create_dir_all(&src_root).unwrap();
std::fs::write(src_root.join("lib.rs"), "pub fn alpha() {}\n").unwrap();
std::fs::write(
wiki.join("api.md"),
"# API 参考\n\n## m\n\n- `alpha` — m.rs:1\n- `beta` — m.rs:2\n",
)
.unwrap();
let issues = lint(&dir, &[src_root]);
let stale: Vec<_> = issues.iter().filter(|i| i.kind == "stale-entity").collect();
assert_eq!(stale.len(), 1, "只应报已删除的 beta, 实际: {:?}", issues);
assert!(stale[0].message.contains("beta"), "应指向 beta: {}", stale[0].message);
assert!(!stale[0].message.contains("alpha"), "源码存在的实体不应误报");
let empty_root = dir.join("empty_src");
std::fs::create_dir_all(&empty_root).unwrap();
let issues2 = lint(&dir, &[empty_root]);
assert!(
!issues2.iter().any(|i| i.kind == "stale-entity"),
"空源码根应跳过 stale-entity 检查, 实际: {:?}",
issues2
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_bad_citation_rejects_dotdot() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_dotdot_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki");
let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::create_dir_all(dir.parent().unwrap().join("escape_dir")).unwrap();
std::fs::write(
dir.parent().unwrap().join("escape_dir").join("x.rs"),
"line1\n",
)
.unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n核心逻辑见 `../escape_dir/x.rs:1`\n",
)
.unwrap();
let issues = lint(&out, &[]);
let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-citation").collect();
assert_eq!(bad.len(), 1, "越界段应报 bad-citation, 实际: {:?}", issues);
assert!(bad[0].message.contains("越界段 .."), "消息应说明越界: {}", bad[0].message);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_vctx_valid_passes() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_ok_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki");
let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("real.rs"), "hello\n").unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n核心逻辑见 [[vctx:src/real.rs#L-1-L-1@2cf24dba]]\n",
)
.unwrap();
let issues = lint(&out, &[]);
assert!(
!issues.iter().any(|i| i.kind == "bad-vctx"),
"合法 vctx 标记不应报错, 实际: {:?}",
issues
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_vctx_missing_file_and_malformed() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_miss_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki");
let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n- [[vctx:src/ghost.rs#L-1-L-1@2cf24dba]]\n- [[vctx:src/real.rs]]\n",
)
.unwrap();
let issues = lint(&out, &[]);
let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-vctx").collect();
assert_eq!(bad.len(), 2, "缺失文件与格式不完整各报一条, 实际: {:?}", issues);
assert!(
bad.iter().any(|i| i.message.contains("ghost.rs")),
"应指向缺失文件: {:?}",
issues
);
assert!(
bad.iter().any(|i| i.message.contains("格式不完整")),
"应报格式不完整: {:?}",
issues
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_vctx_range_out_of_bounds() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_range_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki");
let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("small.rs"), "line1\nline2\n").unwrap();
std::fs::write(
wiki.join("m.md"),
"# M\n\n见 [[vctx:src/small.rs#L-3-L-3@2cf24dba]]\n",
)
.unwrap();
let issues = lint(&out, &[]);
let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-vctx").collect();
assert_eq!(bad.len(), 1, "越界引用应报 bad-vctx, 实际: {:?}", issues);
assert!(bad[0].message.contains("越界"), "消息应说明越界: {}", bad[0].message);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_lint_vctx_hash_mismatch() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_lint_vctx_hash_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join(".code-repo-wiki");
let wiki = out.join("wiki").join("zh");
std::fs::create_dir_all(&wiki).unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
let old_hash = vctx_line_hash("line1\nline2\n", 1, 2);
std::fs::write(dir.join("src").join("lib.rs"), "changed\nline2\n").unwrap();
std::fs::write(
wiki.join("m.md"),
format!("# M\n\n见 [[vctx:src/lib.rs#L-1-L-2@{old_hash}]]\n"),
)
.unwrap();
let issues = lint(&out, &[]);
let bad: Vec<_> = issues.iter().filter(|i| i.kind == "bad-vctx").collect();
assert_eq!(bad.len(), 1, "内容变更后旧哈希应报错, 实际: {:?}", issues);
assert!(bad[0].message.contains("哈希不匹配"), "消息应说明哈希不一致: {}", bad[0].message);
let _ = std::fs::remove_dir_all(&dir);
}
}