gitlink-cli/examples/research/repro-audit/scripts/repro_audit.py

263 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""科研项目复现性审计repro-audit
面向科研代码仓库的确定性复现性评估:
1. 采集:`repo +tree` 获取仓库结构,`file +view --raw` 拉取 README`release +list` 查发布
2. 评估:八个维度的复现性检查项(文档/许可证/引用/依赖/入口/数据说明/测试/版本固化)
3. 报告0-100 评分卡 + A/B/C/D 等级 + 逐项修复建议
4. 回写:--apply 时用 `issue +create` 把报告作为改进 tracking issue 提交
纯标准库实现Python >= 3.9gitlink-cli 为唯一外部依赖。默认 dry-run。
"""
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CheckResult:
name: str
weight: int
score: int # 0..weight
evidence: str
advice: str
def run_cli(args, cli="gitlink-cli"):
result = subprocess.run([cli, *args], capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(
f"gitlink-cli {' '.join(args)} 失败: {result.stderr.strip() or result.stdout.strip()}"
)
return result.stdout
def get_json(args, cli="gitlink-cli"):
payload = json.loads(run_cli([*args, "--format", "json"], cli=cli))
return payload.get("data", payload)
def list_entries(owner, repo, ref, path="", cli="gitlink-cli"):
args = ["repo", "+tree", "--owner", owner, "--repo", repo]
if ref:
args += ["--ref", ref]
if path:
args += ["--path", path]
data = get_json(args, cli=cli)
entries = data.get("entries", data) if isinstance(data, dict) else data
out = []
if isinstance(entries, list):
for e in entries:
if isinstance(e, dict) and e.get("name"):
out.append((e["name"], e.get("type", "")))
return out
def fetch_readme(owner, repo, ref, names, cli="gitlink-cli"):
for name in names:
if re.match(r"(?i)^readme(\.|$)", name):
args = ["file", "+view", "--owner", owner, "--repo", repo, "--path", name, "--raw"]
if ref:
args += ["--ref", ref]
try:
return name, run_cli(args, cli=cli)
except RuntimeError:
continue
return None, ""
def count_releases(owner, repo, cli="gitlink-cli"):
try:
data = get_json(["release", "+list", "--owner", owner, "--repo", repo], cli=cli)
except RuntimeError:
return 0
releases = data.get("releases", data) if isinstance(data, dict) else data
return len(releases) if isinstance(releases, list) else 0
DEP_FILES = [
"requirements.txt", "environment.yml", "environment.yaml", "Pipfile",
"pyproject.toml", "setup.py", "go.mod", "package.json", "Cargo.toml",
"pom.xml", "build.gradle", "CMakeLists.txt", "DESCRIPTION", "renv.lock",
]
ENTRY_FILES = ["Makefile", "makefile", "run.sh", "train.sh", "main.py", "run.py", "train.py", "Dockerfile", "docker-compose.yml"]
CITATION_FILES = ["CITATION.cff", "CITATION", "CITATION.bib", "citation.bib"]
LICENSE_RE = re.compile(r"(?i)^(license|licence|copying)(\.|$)")
DATA_HINT_RE = re.compile(r"(?i)\b(dataset|data set|数据集|数据来源|download.*data|data/)\b")
REPRO_HINT_RE = re.compile(r"(?i)(reproduc|复现|实验设置|experiment setup|how to run|usage|快速开始|quick\s?start)")
def audit(names, dirs, readme_text, release_count):
"""确定性打分names=顶层文件名dirs=顶层目录名。返回 CheckResult 列表。"""
nameset = set(names)
results = []
def add(name, weight, ok, evidence, advice, partial=None):
score = weight if ok else (partial if partial else 0)
results.append(CheckResult(name, weight, score, evidence, "" if ok else advice))
readme_name = next((n for n in names if re.match(r"(?i)^readme(\.|$)", n)), None)
has_usage = bool(readme_text and REPRO_HINT_RE.search(readme_text))
add("README 与运行说明", 15, bool(readme_name and has_usage),
f"README={readme_name or '缺失'};运行/复现章节={'' if has_usage else '未检出'}",
"补充 README 并加入 How to run / 复现步骤章节",
partial=8 if readme_name else 0)
lic = next((n for n in names if LICENSE_RE.match(n)), None)
add("开源许可证", 15, bool(lic), f"许可证文件={lic or '缺失'}",
"添加 LICENSE 文件(科研代码推荐 MIT/Apache-2.0/BSD")
cit = next((n for n in names if n in CITATION_FILES), None)
cite_in_readme = bool(readme_text and re.search(r"(?i)(citation|引用|bibtex|@(article|inproceedings))", readme_text))
add("引用信息", 10, bool(cit or cite_in_readme),
f"CITATION 文件={cit or ''}README 引用章节={'' if cite_in_readme else ''}",
"添加 CITATION.cff 或在 README 中给出 BibTeX")
dep = next((n for n in names if n in DEP_FILES), None)
add("依赖清单(环境固化)", 15, bool(dep), f"依赖文件={dep or '缺失'}",
"添加 requirements.txt / environment.yml 等依赖清单并固定版本")
entry = next((n for n in names if n in ENTRY_FILES), None)
script_dir = next((d for d in dirs if d in ("scripts", "bin")), None)
add("运行入口", 10, bool(entry or script_dir),
f"入口={entry or script_dir or '未检出'}",
"提供 Makefile / run.sh / main.py 等一键运行入口")
data_dir = next((d for d in dirs if re.match(r"(?i)^(data|datasets?)$", d)), None)
data_in_readme = bool(readme_text and DATA_HINT_RE.search(readme_text))
add("数据可得性说明", 10, bool(data_dir or data_in_readme),
f"数据目录={data_dir or ''}README 数据说明={'' if data_in_readme else ''}",
"在 README 中说明数据集来源、获取方式与预处理步骤")
test_dir = next((d for d in dirs if re.match(r"(?i)^tests?$", d)), None)
add("测试/验证代码", 10, bool(test_dir), f"测试目录={test_dir or ''}",
"添加 tests/ 验证关键结果可复算")
add("版本发布(成果固化)", 15, release_count > 0, f"release 数={release_count}",
"为论文对应的代码状态打 tag 并创建 release")
return results
GRADE = [(85, "A可复现性良好"), (70, "B基本可复现"), (50, "C存在明显缺口"), (0, "D复现困难")]
def render_report(owner, repo, ref, results):
total = sum(r.score for r in results)
grade = next(g for t, g in GRADE if total >= t)
lines = [
f"# 科研复现性审计报告:{owner}/{repo}ref: {ref or '默认分支'}",
"",
f"**总分:{total}/100 — 等级 {grade}**",
"",
"| 检查项 | 得分 | 证据 | 建议 |",
"|--------|------|------|------|",
]
for r in results:
lines.append(f"| {r.name} | {r.score}/{r.weight} | {r.evidence} | {r.advice or ''} |")
lines += ["", "---", "*由 repro-audit 生成(确定性检查,同输入同输出)。*"]
return "\n".join(lines), total
def audit_repo(owner, repo, ref, out_dir, cli, apply_issue=False, echo=True):
"""审计单个仓库,落盘报告,返回总分。"""
entries = list_entries(owner, repo, ref, cli=cli)
names = [n for n, t in entries if t != "dir"]
dirs = [n for n, t in entries if t == "dir"]
_, readme_text = fetch_readme(owner, repo, ref, names, cli=cli)
releases = count_releases(owner, repo, cli=cli)
results = audit(names, dirs, readme_text, releases)
report, total = render_report(owner, repo, ref, results)
path = out_dir / f"repro-audit-{owner}-{repo}.md"
path.write_text(report, encoding="utf-8")
if echo:
print(report)
print(f"\n报告已保存:{path}", file=sys.stderr)
if apply_issue:
run_cli([
"issue", "+create", "--owner", owner, "--repo", repo,
"--title", f"[repro-audit] 复现性审计报告({total}/100",
"--body", report, "--format", "json",
], cli=cli)
print("已创建 tracking issue。", file=sys.stderr)
return total
def read_repos_file(path):
"""读取批量仓库清单:每行 owner/repo# 开头为注释。"""
repos = []
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
owner, _, repo = line.partition("/")
if not owner or not repo:
raise ValueError(f"无效的仓库行(应为 owner/repo{line}")
repos.append((owner, repo))
return repos
def render_summary(rows, failures=()):
"""批量审计汇总表(确定性:按得分降序、同分按名称;失败仓库单独列出)。"""
rows = sorted(rows, key=lambda r: (-r[1], r[0]))
lines = ["# 批量复现性审计汇总", "", "| 仓库 | 得分 | 等级 |", "|------|------|------|"]
for name, total in rows:
grade = next(g for t, g in GRADE if total >= t)
lines.append(f"| {name} | {total}/100 | {grade} |")
if failures:
lines += ["", "## 无法审计的仓库", ""]
lines += [f"- {name}{reason}" for name, reason in sorted(failures)]
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(description="科研项目复现性审计")
parser.add_argument("--owner")
parser.add_argument("--repo")
parser.add_argument("--repos-file", help="批量审计清单文件(每行 owner/repo# 注释)")
parser.add_argument("--ref", default="")
parser.add_argument("--apply", action="store_true", help="把报告作为 tracking issue 回写(默认 dry-run")
parser.add_argument("--output-dir", default="outputs")
parser.add_argument("--cli", default="gitlink-cli")
args = parser.parse_args()
if not args.repos_file and not (args.owner and args.repo):
parser.error("需要 --owner 与 --repo或 --repos-file")
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if args.repos_file:
rows, failures = [], []
for owner, repo in read_repos_file(args.repos_file):
name = f"{owner}/{repo}"
try:
total = audit_repo(owner, repo, args.ref, out_dir, args.cli,
apply_issue=args.apply, echo=False)
rows.append((name, total))
except RuntimeError as exc:
print(f"跳过 {name}{exc}", file=sys.stderr)
failures.append((name, str(exc)))
summary = render_summary(rows, failures)
summary_path = out_dir / "repro-audit-summary.md"
summary_path.write_text(summary, encoding="utf-8")
print(summary)
print(f"汇总已保存:{summary_path}", file=sys.stderr)
return 0 if not failures and all(t >= 70 for _, t in rows) else 2
total = audit_repo(args.owner, args.repo, args.ref, out_dir, args.cli,
apply_issue=args.apply)
return 0 if total >= 70 else 2
if __name__ == "__main__":
sys.exit(main())