Files
agent-radar/scripts/validate.py
T
gitea-publish 8be2e8e611 fix(mirror): 翻 public 的时机、快照强推、上游不可用时降级
四处修复,都来自本次重跑 openwork 时实际踩到的失败:

1. 翻 public 从「推送之前」移到「推送成功之后」
   原实现先翻可见性再推,踩出「仓库已 public + 内容还是旧快照」的窗口期:
   --set-public 成功 → 推送被拒(source-only 历史不连续) → 公开了旧版本。
   旧内容虽不含 ee/(合法),但公开了不该公开的版本,正是最该避免的局面。
   正确顺序:先推对内容,再翻可见性;任一步失败都停在 private,无害。
   收紧方向(private)仍留在同步前——锁起来永远安全,早做没代价。

2. source-only 快照改强推
   每次快照都是 checkout → git init → 单次 commit,与远端上次快照无血缘,
   永远不可能 fast-forward。改了排除项后重跑必被拒:"! [rejected] fetch first"。
   强推在此语义正确:source-only 保的是当前源码状态,不是历史链。

3. 新增 --no-fetch
   GitHub 连不上(Empty reply from server)时脚本在 fetch 阶段就退出,
   本地裸仓明明有 18763 条 commit 却什么都做不了。
   上游不可用不该阻止用已有内容重做快照。

4. 补充 LICENSES/LicenseRef-OpenWork-EE.txt 到 openwork 的 excludes
   详见上一提交,此处是让它真正生效后落库。

新增 scripts/validate.py:档案 schema 校验。
起因是我手写临时探针把 risk.claim_vs_reality 写成顶层字段,
得到「该字段 0 条」的错误结论——层级写错不报错,只会安静返回 0。
校验项含:核心字段完整性、claim_vs_reality 下限与 source 必填、
excludes 与 redistributable_after_excludes 的一致性、
以及自动抓「七个里」这类随样本增加即过时的计数表述。
当前 7 个项目全部通过。
2026-09-02 01:07:11 +08:00

175 lines
6.8 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
"""档案 schema 校验:在发布/镜像之前把结构性缺口挡住。
为什么需要这个
--------------
2026-09-02 的教训:我手写了一个临时探针去查某个档案的 claim_vs_reality,
把 `risk.claim_vs_reality` 写成了顶层 `claim_vs_reality`,
于是得到一个"该字段为 0 条"的错误结论,差点据此去补一个根本不存在的缺口。
问题不在探针写错——**每次都靠临时手写探针,出错是迟早的事**。
写错的那一层恰好是层级,肉眼极难发现,因为它不会报错,只会安静地返回 0。
所以这个脚本的职责是:把"档案应该长什么样"固化成一处可执行的规则,
而不是散落在每次的临时检查里。
用法
----
python scripts/validate.py # 校验全部档案,有问题则退出码 1
python scripts/validate.py --id orca # 只校验一个
退出码:0 = 全部通过;1 = 有 ERROR;2 = 只有 WARN(--strict 时也返回 1)
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _common import PROJECTS, load_yaml # noqa: E402
# ---- 必填结构 ----
REQUIRED_TOP = [
"id", "name", "repo", "identity", "business", "scenario", "tech",
"ecosystem", "risk", "chart", "verdict", "mirror",
]
REQUIRED_LICENSE = ["spdx", "openness", "redistributable"]
REQUIRED_CHART = ["form_depth", "engine_neutral", "local_first_score", "deliverable_depth"]
REQUIRED_MIRROR = ["upstream", "gitea_repo", "visibility", "mode"]
# claim_vs_reality 是整套体系的立身之本:
# "宣称 vs 实测落差"是这个追踪体系区别于 README 复读机的唯一东西。
# 少于 MIN_CVR 条基本等于没做深度分析。
MIN_CVR = 3
# 会随样本增加而立即过时的表述。
# 2026-09-02:maka 的 one_liner 写着「四个里唯一」,那时样本已经 7 个了——
# 这种话每加一批样本就错一次,而且错得很隐蔽(数字还在,只是不对了)。
# 「已收录样本中」是隐含的,不必写数字。
COUNT_WORDS = re.compile(r"(七个里|七个样本里|四个里|四个样本里|\d+\s*个里|\d+\s*个样本里)")
def validate_one(path: Path) -> list[tuple[str, str, str]]:
"""返回 [(级别, 位置, 说明)],级别为 ERROR / WARN。"""
issues: list[tuple[str, str, str]] = []
d = load_yaml(path) or {}
pid = d.get("id") or path.stem
def err(where: str, msg: str) -> None:
issues.append(("ERROR", f"{pid}.{where}", msg))
def warn(where: str, msg: str) -> None:
issues.append(("WARN", f"{pid}.{where}", msg))
# ---- 顶层字段 ----
for k in REQUIRED_TOP:
if not d.get(k):
err(k, "必填字段缺失或为空")
# ---- 许可证 ----
lic = (d.get("business") or {}).get("license") or {}
for k in REQUIRED_LICENSE:
if k not in lic:
err(f"business.license.{k}", "许可证块缺该字段")
# ---- 目录切分许可的排除后判定 ----
# 与 mirror_sync.sh 的 fail-closed 规则保持一致:
# 声明了 excludes 就必须给出排除后的可分发性,否则镜像脚本会直接拒绝。
# 在这里提前报,比跑到镜像那一步才炸要好定位得多。
mirror = d.get("mirror") or {}
if mirror.get("excludes") and "redistributable_after_excludes" not in lic:
err("business.license.redistributable_after_excludes",
"档案声明了 mirror.excludes,但许可证块没有排除后的可分发性判定,"
"mirror_sync.sh 会拒绝执行")
# ---- claim_vs_reality ----
cvr = (d.get("risk") or {}).get("claim_vs_reality") or []
if len(cvr) < MIN_CVR:
err("risk.claim_vs_reality",
f"仅 {len(cvr)} 条,少于下限 {MIN_CVR}。这是本体系的核心字段,"
f"不足意味着该样本没做深度分析")
for i, item in enumerate(cvr):
if not isinstance(item, dict):
err(f"risk.claim_vs_reality[{i}]", "不是映射结构")
continue
for k in ("claim", "actual", "source"):
if not item.get(k):
err(f"risk.claim_vs_reality[{i}].{k}",
"缺该字段——没有 source 的落差分析只是观点,不是核实过的结论")
# ---- 坐标 ----
chart = d.get("chart") or {}
for k in REQUIRED_CHART:
if chart.get(k) is None:
err(f"chart.{k}", "象限图坐标缺失,该项目不会出现在对比图里")
# ---- mirror ----
for k in REQUIRED_MIRROR:
if not mirror.get(k):
err(f"mirror.{k}", "镜像配置缺该字段")
# ---- 会过时的计数表述 ----
one_liner = str((d.get("verdict") or {}).get("one_liner") or "")
m = COUNT_WORDS.search(one_liner)
if m:
warn("verdict.one_liner",
f"含样本计数「{m.group(1)}」,样本增加后即过时。"
f"去掉数字即可,「已收录样本中」本就是隐含的")
# ---- sync_note 与 mode 自相矛盾 ----
# 2026-09-02:qwenpaw 的 mode 已是 source-only(镜像完成),
# sync_note 却仍写着"尚未执行"。档案里写着与事实相反的话,比没有更糟。
mode = str(mirror.get("mode") or "")
note = str(mirror.get("sync_note") or "")
done_modes = {"full", "source-only"}
stale_words = ("尚未执行", "暂未镜像", "未执行", "待带宽窗口")
if mode in done_modes and any(w in note for w in stale_words):
warn("mirror.sync_note",
f"mirror.mode={mode}(已镜像)但备注仍写着未执行类措辞,档案与事实矛盾")
return issues
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--id", help="只校验指定项目")
ap.add_argument("--strict", action="store_true", help="WARN 也视为失败")
args = ap.parse_args()
if args.id:
files = [PROJECTS / f"{args.id}.yaml"]
if not files[0].exists():
print(f"档案不存在: {files[0]}", file=sys.stderr)
return 1
else:
files = sorted(PROJECTS.glob("*.yaml"))
all_issues: list[tuple[str, str, str]] = []
for f in files:
all_issues.extend(validate_one(f))
n_err = sum(1 for lv, _, _ in all_issues if lv == "ERROR")
n_warn = sum(1 for lv, _, _ in all_issues if lv == "WARN")
print(f"==> 校验 {len(files)} 个项目档案")
for lv, where, msg in all_issues:
mark = "✗" if lv == "ERROR" else "⚠"
print(f" {mark} [{lv}] {where}: {msg}")
if not all_issues:
print(" 全部通过")
print(f" 合计:{n_err} 错误,{n_warn} 警告")
if n_err:
return 1
if n_warn and args.strict:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())