Skip to main content

code_repo_wiki/bench/
manifest.rs

1//! 第二档评测:多仓库清单跑分框架(v21 E 组,t10)
2//!
3//! 用途:对一批仓库(本地路径或 git URL)批量执行
4//! Coverage / Doc Info / lint / Time 四个快维度,输出仓库×维度矩阵。
5//! 设计决策:
6//! - Update Recall(git commit 回放)在清单模式**跳过**——批量场景下
7//!   回放会 reset --hard 工作区,安全闸误触与成本都不可接受;需要
8//!   深度回放请用单仓库 `bench`(含 `--rubrics-only` 裁判模式)。
9//! - 每个仓库的产物输出到 `work_dir/<仓库名>-out/`(覆盖模板配置的
10//!   `output.dir`),互不污染;模板配置其余字段(scope/llm/…)原样生效。
11//! - 单仓库失败(clone 失败/扫描失败)记录到该行 `error` 字段后继续——
12//!   批量评测的外部依赖失败不应中断整批(与流水线"失败只告警"策略一致)。
13//! - 清单格式:每行一个仓库;`#` 开头为注释;空行跳过;
14//!   本地路径直接使用,`https://…` / `git@…` 视为远程 URL(clone 到 work_dir)。
15
16use std::path::{Path, PathBuf};
17use std::time::Instant;
18
19use anyhow::{bail, Context, Result};
20
21use crate::config::schema::WikiConfig;
22use crate::project::ProjectRoot;
23
24use super::{collect_wiki_pages, measure_coverage, measure_doc_info, measure_lint, DocInfoReport, LintReport, TimeReport, CoverageReport};
25
26/// 清单中的单个仓库条目(本地路径与远程 URL 二选一;commit 可选钉死)
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RepoEntry {
29    /// 仓库名(远程 URL 必须显式指定;本地路径缺省取目录名)
30    pub name: String,
31    /// 远程 git URL(Some 时 local 必须为 None)
32    pub url: Option<String>,
33    /// 本地仓库路径(Some 时 url 必须为 None)
34    pub local: Option<PathBuf>,
35    /// 评测基准 commit(Some 时执行前 checkout 钉死——CodeWikiBench/
36    /// knowing 类评测要求 commit 级可复现;None 用 HEAD)
37    pub commit: Option<String>,
38}
39
40/// 单仓库跑分结果(error 非空表示该仓库未完成评测)
41#[derive(Debug, Clone, serde::Serialize)]
42pub struct RepoReport {
43    pub name: String,
44    pub coverage: CoverageReport,
45    pub doc_info: DocInfoReport,
46    pub lint: LintReport,
47    pub time: TimeReport,
48    /// 该仓库评测失败原因(clone/扫描/生成失败);None=完成
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub error: Option<String>,
51}
52
53/// 清单跑分聚合报告(确定性:仓库顺序 = 清单顺序)
54#[derive(Debug, Clone, serde::Serialize)]
55pub struct ManifestReport {
56    /// 评测时间(ISO 8601)
57    pub generated_at: String,
58    pub repos: Vec<RepoReport>,
59}
60
61/// 解析清单文件:每行一个仓库(`#` 注释/空行跳过)
62///
63/// 行格式:
64/// - 本地路径:`D:\path\to\repo` 或 `./relative`(相对清单文件所在目录解析)
65/// - 远程 URL:`https://github.com/owner/repo`(仓库名取 URL 最后一段,去 `.git`)
66/// - 带名形式:`名字 | 路径或URL`(路径/URL 中的 `|` 需谨慎,文档约定名字在前)
67/// - 带 commit 形式:`名字 | 路径或URL | commit`(v28 t02 扩展:评测基准
68///   钉死为指定 commit,保证跨次可复现;缺省 None 用 HEAD)
69pub fn parse_manifest(path: &Path) -> Result<Vec<RepoEntry>> {
70    let content = std::fs::read_to_string(path)
71        .with_context(|| format!("读取清单文件失败: {}", path.display()))?;
72    let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
73    let mut entries = Vec::new();
74    for (idx, raw) in content.lines().enumerate() {
75        let line = raw.trim();
76        if line.is_empty() || line.starts_with('#') {
77            continue;
78        }
79        // 行内分隔:无 `|` → 整行即目标;有 → 1-2 个 `|` 分隔 name/target/commit
80        let (name, target, commit) = match line.split_once('|') {
81            None => (String::new(), line.to_string(), None),
82            Some(_) => {
83                let mut parts = line.splitn(3, '|').map(str::trim);
84                let n = parts.next().unwrap_or("").to_string();
85                let t = parts.next().unwrap_or("").to_string();
86                let c = parts.next().map(str::to_string);
87                (n, t, c)
88            }
89        };
90        if target.is_empty() {
91            bail!("清单第 {} 行解析失败(缺少仓库路径): {line}", idx + 1);
92        }
93        // 远程 URL 判定:http(s):// 或 git@ 前缀
94        let entry = if target.starts_with("http://")
95            || target.starts_with("https://")
96            || target.starts_with("git@")
97        {
98            let name = if name.is_empty() {
99                // 取 URL 最后一段作为仓库名(去 .git 后缀)
100                target
101                    .trim_end_matches('/')
102                    .rsplit(['/', ':'])
103                    .next()
104                    .unwrap_or("unknown")
105                    .trim_end_matches(".git")
106                    .to_string()
107            } else {
108                name.to_string()
109            };
110            RepoEntry { name, url: Some(target.to_string()), local: None, commit }
111        } else {
112            let name = if name.is_empty() {
113                // 平台无关的路径文件名提取:反斜杠在非 Windows 平台不是分隔符,
114                // 但清单里可能含 Windows 盘符路径(如 D:\tmp\repo-a),统一按
115                // `/` 与 `\` 双分隔符取最后一段,避免 ubuntu 上把整串当文件名。
116                target
117                    .rsplit(['/', '\\'])
118                    .next()
119                    .unwrap_or("unknown")
120                    .to_string()
121            } else {
122                name
123            };
124            // 平台无关绝对路径判定:Windows 盘符路径(D:\x 或 D:/x)在非
125            // Windows 平台 is_absolute() 为 false,但清单语义上它就是绝对
126            // 路径——按盘符前缀识别,避免被 join 进清单文件所在目录
127            let is_win_drive = target.len() >= 3
128                && target.as_bytes()[1] == b':'
129                && (target.as_bytes()[2] == b'\\' || target.as_bytes()[2] == b'/')
130                && target.as_bytes()[0].is_ascii_alphabetic();
131            // 相对路径基于清单文件所在目录解析(本地路径不校验存在性——
132            // 执行期统一失败标注,便于批处理继续)
133            let p = PathBuf::from(target);
134            let abs = if p.is_absolute() || is_win_drive {
135                p
136            } else {
137                manifest_dir.join(p)
138            };
139            RepoEntry { name, url: None, local: Some(abs), commit }
140        };
141        if entry.name.is_empty() {
142            bail!("清单第 {} 行解析失败(仓库名为空): {line}", idx + 1);
143        }
144        entries.push(entry);
145    }
146    Ok(entries)
147}
148
149/// 执行清单跑分:对每个仓库跑 mock/模板生成 + 四快维度测量
150///
151/// - `template_config`:模板配置(scope/llm/provider 等;`output.dir` 会被
152///   每仓库覆盖为 `work_dir/<name>-out/`)
153/// - `work_dir`:远程仓库 clone 落地目录 + 每仓库产物目录的父目录
154/// - 远程 URL 克隆失败/本地路径不存在 → 该仓库 `error` 标注,继续下一仓库
155pub fn run_manifest(
156    entries: &[RepoEntry],
157    template_config: &WikiConfig,
158    work_dir: &Path,
159) -> Result<ManifestReport> {
160    std::fs::create_dir_all(work_dir)
161        .with_context(|| format!("创建 work_dir 失败: {}", work_dir.display()))?;
162
163    // 模板配置落盘(run_pipeline 从文件加载;output 覆盖由调用方传参)
164    let template_path = work_dir.join("template-config.toml");
165    std::fs::write(&template_path, toml::to_string_pretty(template_config)?)
166        .with_context(|| "写模板配置文件失败")?;
167
168    let mut repos = Vec::with_capacity(entries.len());
169    for entry in entries {
170        let start = Instant::now();
171        // 解析仓库本地路径(远程 clone 到 work_dir/<name>)
172        let repo_path = match (&entry.url, &entry.local) {
173            (Some(url), _) => {
174                let dest = work_dir.join(&entry.name);
175                if !dest.exists()
176                    && let Err(e) = git2::Repository::clone(url, &dest)
177                {
178                    repos.push(RepoReport {
179                        name: entry.name.clone(),
180                        coverage: empty_coverage(),
181                        doc_info: empty_doc_info(),
182                        lint: empty_lint(),
183                        time: empty_time(),
184                        error: Some(format!("clone 失败: {e}")),
185                    });
186                    continue;
187                }
188                dest
189            }
190            (None, Some(local)) => {
191                if !local.is_dir() {
192                    repos.push(RepoReport {
193                        name: entry.name.clone(),
194                        coverage: empty_coverage(),
195                        doc_info: empty_doc_info(),
196                        lint: empty_lint(),
197                        time: empty_time(),
198                        error: Some(format!("本地路径不存在: {}", local.display())),
199                    });
200                    continue;
201                }
202                local.clone()
203            }
204            (None, None) => {
205                repos.push(RepoReport {
206                    name: entry.name.clone(),
207                    coverage: empty_coverage(),
208                    doc_info: empty_doc_info(),
209                    lint: empty_lint(),
210                    time: empty_time(),
211                    error: Some("清单条目既无 url 也无 local".to_string()),
212                });
213                continue;
214            }
215        };
216
217        // commit 钉死:清单指定评测基准 commit 时 checkout(v28 t02)
218        // 评测可复现性要求 commit 级确定性;本地路径条目同样生效
219        if let Some(commit) = &entry.commit
220            && let Err(e) = checkout_commit(&repo_path, commit)
221        {
222            repos.push(RepoReport {
223                name: entry.name.clone(),
224                coverage: empty_coverage(),
225                doc_info: empty_doc_info(),
226                lint: empty_lint(),
227                time: empty_time(),
228                error: Some(format!("checkout {commit} 失败: {e}")),
229            });
230            continue;
231        }
232
233        // 每仓库独立产物目录:work_dir/<name>-out/(覆盖模板 output.dir)
234        let out_dir = work_dir.join(format!("{}-out", entry.name));
235        let root = ProjectRoot::new(repo_path);
236        let res = crate::run_pipeline(
237            Some(&template_path),
238            Some(&out_dir),
239            true, // force:清单跑分语义=全量重建后测量
240            &root,
241            &crate::GenerationMode::Full,
242        );
243        let result = match res {
244            Ok(_) => {
245                let pages = collect_wiki_pages(&out_dir);
246                let cov = measure_coverage(&root, &pages);
247                let doc_info = measure_doc_info(&pages);
248                let lint = measure_lint(&out_dir, &root);
249                RepoReport {
250                    name: entry.name.clone(),
251                    coverage: cov.unwrap_or_else(|e| {
252                        RepoReport {
253                            name: entry.name.clone(),
254                            coverage: empty_coverage(),
255                            doc_info: empty_doc_info(),
256                            lint: empty_lint(),
257                            time: empty_time(),
258                            error: Some(format!("测量失败: {e}")),
259                        }
260                        .coverage
261                    }),
262                    doc_info,
263                    lint,
264                    time: TimeReport {
265                        scan_ms: 0,
266                        generate_ms: 0,
267                        total_ms: start.elapsed().as_millis() as u64,
268                    },
269                    error: None,
270                }
271            }
272            Err(e) => RepoReport {
273                name: entry.name.clone(),
274                coverage: empty_coverage(),
275                doc_info: empty_doc_info(),
276                lint: empty_lint(),
277                time: empty_time(),
278                error: Some(format!("生成失败: {e}")),
279            },
280        };
281        repos.push(result);
282    }
283
284    Ok(ManifestReport {
285        generated_at: chrono::Utc::now().to_rfc3339(),
286        repos,
287    })
288}
289
290fn empty_coverage() -> CoverageReport {
291    CoverageReport { total_entities: 0, covered_entities: 0, ratio: 0.0 }
292}
293fn empty_doc_info() -> DocInfoReport {
294    DocInfoReport {
295        pages: 0,
296        words: 0,
297        cross_references: 0,
298        code_blocks: 0,
299        diagrams: 0,
300        llm_judged: false,
301        llm_score: 0.0,
302        llm_judged_modules: 0,
303        llm_abstain_modules: 0,
304    }
305}
306fn empty_lint() -> LintReport {
307    LintReport { total_issues: 0, by_kind: Default::default() }
308}
309fn empty_time() -> TimeReport {
310    TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 }
311}
312
313/// 渲染清单跑分 Markdown 矩阵(确定性:仓库顺序 = 清单顺序,不排序)
314pub fn render_manifest_markdown(report: &ManifestReport) -> String {
315    let mut out = String::new();
316    out.push_str(&format!("# 清单跑分报告({} 仓库)\n\n", report.repos.len()));
317    out.push_str(&format!("> 生成时间: {}\n\n", report.generated_at));
318    out.push_str("| 仓库 | 实体 | 覆盖 | 页面 | 词数 | 交叉引用 | 代码块 | 图 | lint 问题 | 耗时(ms) |\n");
319    out.push_str("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n");
320    for r in &report.repos {
321        if let Some(err) = &r.error {
322            out.push_str(&format!("| {} | — | — | — | — | — | — | — | **失败**: {err} | — |\n", r.name));
323        } else {
324            out.push_str(&format!(
325                "| {} | {} | {:.1}% | {} | {} | {} | {} | {} | {} | {} |\n",
326                r.name,
327                r.coverage.total_entities,
328                r.coverage.ratio * 100.0,
329                r.doc_info.pages,
330                r.doc_info.words,
331                r.doc_info.cross_references,
332                r.doc_info.code_blocks,
333                r.doc_info.diagrams,
334                r.lint.total_issues,
335                r.time.total_ms,
336            ));
337        }
338    }
339    out.push('\n');
340    out
341}
342
343/// checkout 钉死 commit:解析 `commit` 为仓库内对象并检出到工作树
344///
345/// 设计约束:
346/// - 只接受可解析的 commit 引用(SHA 前缀/分支/标签均可——git2 语义),
347///   失败返回错误由调用方标注该仓库失败(评测基准不可复现必须显式失败,
348///   不得静默用 HEAD 顶替——那会让跨次结果失去可比性)。
349/// - 失败时**不清理已有工作区**(保留 clone 产物便于人工排查)。
350fn checkout_commit(repo_path: &Path, commit: &str) -> Result<()> {
351    let repo = git2::Repository::open(repo_path)
352        .with_context(|| format!("打开仓库失败: {}", repo_path.display()))?;
353    let obj = repo
354        .revparse_single(commit)
355        .with_context(|| format!("commit 无法解析: {commit}"))?;
356    repo.checkout_tree(&obj, None)
357        .with_context(|| format!("checkout 树失败: {commit}"))?;
358    repo.set_head_detached(obj.id())
359        .with_context(|| "设置 HEAD 失败".to_string())?;
360    Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    /// 清单解析:注释/空行/本地路径/URL/带名形式
368    #[test]
369    fn test_parse_manifest_formats() {
370        let dir = std::env::temp_dir().join(format!("rw_manifest_parse_{}", std::process::id()));
371        std::fs::create_dir_all(&dir).unwrap();
372        let manifest = dir.join("manifest.txt");
373        std::fs::write(
374            &manifest,
375            "# 注释行\n\nD:\\tmp\\repo-a\nhttps://github.com/owner/repo-b.git\n命名仓库 | ./local-c\n钉死版本 | https://github.com/owner/repo-d.git | abc1234\n",
376        )
377        .unwrap();
378        let entries = parse_manifest(&manifest).unwrap();
379        assert_eq!(entries.len(), 4);
380        assert_eq!(entries[0].name, "repo-a");
381        assert_eq!(entries[0].local.as_deref(), Some(Path::new("D:\\tmp\\repo-a")));
382        assert_eq!(entries[0].commit, None);
383        assert_eq!(entries[1].name, "repo-b");
384        assert_eq!(entries[1].url.as_deref(), Some("https://github.com/owner/repo-b.git"));
385        assert_eq!(entries[2].name, "命名仓库");
386        assert_eq!(entries[3].name, "钉死版本");
387        assert_eq!(entries[3].url.as_deref(), Some("https://github.com/owner/repo-d.git"));
388        assert_eq!(entries[3].commit.as_deref(), Some("abc1234"));
389        let _ = std::fs::remove_dir_all(&dir);
390    }
391
392    /// checkout 钉死行为:临时 git 仓库两 commit,检出第一 commit 后工作树回退
393    #[test]
394    fn test_checkout_commit_pins_worktree() {
395        use git2::Repository;
396        let base = std::env::temp_dir().join(format!("rw_manifest_checkout_{}", std::process::id()));
397        std::fs::create_dir_all(&base).unwrap();
398        let repo = Repository::init(&base).unwrap();
399        // 固定 autocrlf=false:GitHub Windows runner 的系统级 Git 配置
400        // (Git for Windows 默认 core.autocrlf=true)会被 libgit2 读取,
401        // checkout 时把内容转 CRLF 导致断言失败——测试内钉死行为,与平台无关
402        repo.config().unwrap().set_bool("core.autocrlf", false).unwrap();
403        let sig = git2::Signature::now("t", "t@t").unwrap();
404        let mut idx = repo.index().unwrap();
405        std::fs::write(base.join("f.txt"), "v1\n").unwrap();
406        idx.add_path(Path::new("f.txt")).unwrap();
407        idx.write().unwrap();
408        let tree1 = idx.write_tree().unwrap();
409        repo.commit(Some("HEAD"), &sig, &sig, "first", &repo.find_tree(tree1).unwrap(), &[]).unwrap();
410        let c1 = repo.head().unwrap().peel_to_commit().unwrap().id();
411        std::fs::write(base.join("f.txt"), "v2\n").unwrap();
412        let mut idx = repo.index().unwrap();
413        idx.add_path(Path::new("f.txt")).unwrap();
414        idx.write().unwrap();
415        let tree2 = idx.write_tree().unwrap();
416        repo.commit(Some("HEAD"), &sig, &sig, "second", &repo.find_tree(tree2).unwrap(), &[&repo.find_commit(c1).unwrap()]).unwrap();
417        assert_eq!(std::fs::read_to_string(base.join("f.txt")).unwrap(), "v2\n");
418        checkout_commit(&base, &c1.to_string()).unwrap();
419        assert_eq!(std::fs::read_to_string(base.join("f.txt")).unwrap(), "v1\n", "钉死后工作树应为第一 commit 内容");
420        assert!(checkout_commit(&base, "deadbeef00").is_err(), "不存在的 commit 应报错");
421        let _ = std::fs::remove_dir_all(&base);
422    }
423
424    /// 清单跑分冒烟:两个本地小仓库(mock 生成确定性),一个本地路径不存在
425    #[test]
426    fn test_run_manifest_smoke() {
427        let base = std::env::temp_dir().join(format!("rw_manifest_run_{}", std::process::id()));
428        std::fs::create_dir_all(&base).unwrap();
429        // 两个小仓库 + 一个不存在的路径
430        for name in ["repo-a", "repo-b"] {
431            let r = base.join(name);
432            std::fs::create_dir_all(r.join("src")).unwrap();
433            std::fs::write(r.join("src").join("main.rs"), "pub fn alpha() {}\n").unwrap();
434        }
435        let manifest_path = base.join("manifest.txt");
436        std::fs::write(
437            &manifest_path,
438            format!("{}\n{}\n{}\n", base.join("repo-a").display(), base.join("repo-b").display(), base.join("missing-repo").display()),
439        )
440        .unwrap();
441
442        let template = WikiConfig {
443            llm: crate::config::schema::LlmSection {
444                provider: crate::config::schema::LlmProviderType::Mock,
445                ..Default::default()
446            },
447            ..Default::default()
448        };
449        let work_dir = base.join("work");
450        let entries = parse_manifest(&manifest_path).unwrap();
451        let report = run_manifest(&entries, &template, &work_dir).unwrap();
452        assert_eq!(report.repos.len(), 3);
453        assert_eq!(report.repos[0].name, "repo-a");
454        assert!(report.repos[0].error.is_none(), "repo-a 应成功: {:?}", report.repos[0].error);
455        assert_eq!(report.repos[0].coverage.total_entities, 1, "应解析出 alpha");
456        assert!(report.repos[0].doc_info.pages > 0, "mock 生成后应有产物页");
457        assert!(report.repos[2].error.is_some(), "缺失路径应标注失败");
458        let md = render_manifest_markdown(&report);
459        assert!(md.contains("| repo-a |"), "矩阵应含 repo-a 行: {md}");
460        assert!(md.contains("**失败**"), "矩阵应含失败标注: {md}");
461        let _ = std::fs::remove_dir_all(&base);
462    }
463}