diff --git a/data/projects/openwork.yaml b/data/projects/openwork.yaml index a269ca4..492eef8 100644 --- a/data/projects/openwork.yaml +++ b/data/projects/openwork.yaml @@ -224,9 +224,9 @@ verdict: mirror: gitea_repo: gitinbox/openwork - visibility: private + visibility: public upstream: "https://github.com/different-ai/openwork" - last_sync: 2026-09-01 + last_sync: 2026-09-02 mode: source-only # 目录切分许可:ee/ 之外是 MIT,ee/ 是 EE License。 # 本档案的 visibility: public **完全建立在「这个排除真的生效」这个前提上**—— diff --git a/scripts/mirror_sync.sh b/scripts/mirror_sync.sh index a0502e8..2d8a85c 100755 --- a/scripts/mirror_sync.sh +++ b/scripts/mirror_sync.sh @@ -24,6 +24,8 @@ SOURCE_ONLY_FORCE="no" # private → public 的显式授权开关。默认 0:公开不可逆,绝不自动放宽。 SET_PUBLIC="0" EXCLUDE_COUNT="0" +# 跳过上游更新(本地裸仓已完整,只为重做快照时用) +NO_FETCH="0" while [[ $# -gt 0 ]]; do case "$1" in @@ -33,6 +35,7 @@ while [[ $# -gt 0 ]]; do --depth) DEPTH="$2"; shift 2;; --no-source-only) SOURCE_ONLY="no"; shift;; --set-public) SET_PUBLIC="1"; shift;; + --no-fetch) NO_FETCH="1"; shift;; *) echo "未知参数: $1" >&2; exit 1;; esac done @@ -146,27 +149,16 @@ if [[ "${VISIBILITY}" == "private" ]]; then -H 'Content-Type: application/json' -d '{"private":true}' "${API}/repos/${ORG}/${REPO_NAME}" || true fi -# ---- 放宽方向(private → public)必须显式授权 ---- -# 公开是不可逆动作:一旦有人 fork/克隆,就再也收不回来。 -# 所以这里**不自动翻**——只报告,要求显式加 --set-public。 -# 2026-09-02 补:原先脚本只有"收紧"方向的修正,openwork 判定为 public 但仓库 -# 停在 private,重跑多少次都不会翻过来,档案与事实不一致却看不出原因。 +# ---- 放宽方向(private → public)推迟到推送成功之后做 ---- +# 2026-09-02 的教训:这段原先放在同步之前,结果 openwork 翻成了 public +# 但推送失败(source-only 快照历史不连续被拒), +# 于是出现「仓库已公开 + 内容仍是旧快照」的窗口期—— +# 虽然旧内容也不含 ee/(合法),但公开了不该公开的版本,正是最该避免的局面。 +# 正确顺序:先推成功、内容对了,再翻可见性。任一步失败都停在 private,无害。 +# 这里只做一次探测,真正的翻转在文件末尾 apply_visibility()。 if [[ "${VISIBILITY}" == "public" ]]; then CUR_PRIVATE="$(curl -sS -u "${GITEA_USER}:${GITEA_TOKEN}" "${API}/repos/${ORG}/${REPO_NAME}" \ | /Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c 'import sys,json;print("true" if json.load(sys.stdin).get("private") else "false")' 2>/dev/null || echo unknown)" - if [[ "${CUR_PRIVATE}" == "true" ]]; then - if [[ "${SET_PUBLIC}" == "1" ]]; then - echo " ⚠ 按 --set-public 将 ${ORG}/${REPO_NAME} 从 private 翻为 public(不可逆)" - curl -sS -o /dev/null -u "${GITEA_USER}:${GITEA_TOKEN}" -X PATCH \ - -H 'Content-Type: application/json' -d '{"private":false}' "${API}/repos/${ORG}/${REPO_NAME}" \ - && echo " 已翻为 public" - else - echo "" - echo " ⚠ ${ORG}/${REPO_NAME} 判定应为 public,但远端当前是 private。" - echo " 公开不可逆,脚本默认不自动翻。确认后重跑并加 --set-public。" - echo " 判定依据:许可证 ${LIC_LEVEL},排除 ${EXCLUDE_COUNT} 项后 redistributable=${VISIBILITY}" - fi - fi fi # ---- 同步 ---- @@ -202,7 +194,17 @@ if [[ -d "${MIRROR_DIR}" ]] && ! repo_ok; then rm -rf "${MIRROR_DIR}" fi -if repo_ok; then +if [[ "${NO_FETCH}" == "1" ]]; then + # 跳过上游更新,直接用本地裸仓生成快照。 + # 2026-09-02 需要这个:本地裸仓已是完整的,只想重跑一次快照(改了排除项), + # 但 GitHub 恰好连不上("Empty reply from server"),脚本在 fetch 阶段就退出, + # 明明有数据却什么都做不了——上游不可用时,不该阻止我们用已有内容重做快照。 + if repo_ok; then + echo " 跳过上游更新(--no-fetch),直接用本地裸仓生成快照" + else + echo "✗ --no-fetch 但本地裸仓不可用:${MIRROR_DIR}" >&2; exit 1 + fi +elif repo_ok; then if is_shallow && [[ -z "${DEPTH}" ]]; then # 慢链路续传:分批 --deepen。每批成功即落盘,中断后重跑本脚本可继续, # 不像 `git clone` 那样失败一次全部作废。 @@ -402,7 +404,32 @@ echo " 推送到 ${ORG}/${REPO_NAME} ..." if [[ "${MIRROR_MODE}" == "full" ]]; then git -C "${PUSH_DIR}" push --mirror "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git" else - git -C "${PUSH_DIR}" push "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git" HEAD:refs/heads/main + # source-only 必须强推:每次快照都是「checkout → git init → 单次 commit」, + # 与远端上一次的快照 commit 毫无血缘关系,永远不可能 fast-forward。 + # 2026-09-02 踩到:改了排除项后重跑,推送被拒 + # "! [rejected] HEAD -> main (fetch first)" + # 但排除校验明明通过了——不是排除没生效,是历史不连续。 + # 强推在这里语义正确:source-only 镜像保的是**当前源码状态**,不是历史链。 + # 若哪天需要保留历史,该做的是增量快照而非回滚这个 force。 + echo " (source-only 快照,强推覆盖:历史不连续是设计使然)" + git -C "${PUSH_DIR}" push --force "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git" HEAD:refs/heads/main +fi + +# ---- 可见性放宽:走到这里说明内容已经推对了,才允许公开 ---- +# 见上方 2026-09-02 的说明:翻转必须发生在成功推送之后。 +# 收紧方向(private)在同步前就做了,因为锁起来永远安全、早做没代价。 +if [[ "${VISIBILITY}" == "public" && "${CUR_PRIVATE}" == "true" ]]; then + if [[ "${SET_PUBLIC}" == "1" ]]; then + echo " ⚠ 内容已推送且排除校验通过 → 将 ${ORG}/${REPO_NAME} 翻为 public(不可逆)" + curl -sS -o /dev/null -u "${GITEA_USER}:${GITEA_TOKEN}" -X PATCH \ + -H 'Content-Type: application/json' -d '{"private":false}' "${API}/repos/${ORG}/${REPO_NAME}" \ + && echo " 已翻为 public" + else + echo "" + echo " ⚠ ${ORG}/${REPO_NAME} 判定应为 public,但远端当前是 private。" + echo " 内容已推送且排除校验通过;公开不可逆,确认后重跑并加 --set-public。" + echo " 判定依据:许可证 ${LIC_LEVEL},排除 ${EXCLUDE_COUNT} 项后 redistributable=public" + fi fi # ---- 回写档案 ---- diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100644 index 0000000..1869c6f --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,174 @@ +#!/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())