diff --git a/.gitea/workflows/contract-gates.yml b/.gitea/workflows/contract-gates.yml index cde0a82b..e063dfc3 100644 --- a/.gitea/workflows/contract-gates.yml +++ b/.gitea/workflows/contract-gates.yml @@ -14,6 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + # 段 A 版本升号闸(check_version_bump)要比对 push 前提交,故拉全历史; + # 其余四门只读工作区、不依赖 git 历史,fetch-depth 变化对它们无影响。 + fetch-depth: 0 # 判例库 python-gates 段需 pytest;check-undef 段需 esbuild(随 node_modules)。 # 尽力预装让四段全量真跑;装不上则相应段 run.mjs 会诚实 SKIP。 @@ -33,3 +37,17 @@ jobs: - name: Prompt Registry 版本一致性 run: python3 contracts/prompts/check_registry.py + + # 四道闸·闸0(段 A):改了 prompt 正文相对 push 前提交必须升 version。 + # 基线优先取 push 事件的 before;取不到/无效回落 HEAD~1;再取不到(首 push/新分支)则跳过不误拦。 + - name: Prompt 闸0 version 升号(改正文必升版) + run: | + BASE="${{ github.event.before }}" + if [ -z "$BASE" ] || ! git rev-parse --verify --quiet "$BASE^{commit}" >/dev/null 2>&1; then + BASE="$(git rev-parse --verify --quiet HEAD~1 2>/dev/null || true)" + fi + if [ -z "$BASE" ]; then + echo "无可比基线(首次提交/新分支),版本升号闸跳过(不误拦、不假绿)" + exit 0 + fi + python3 contracts/prompts/check_version_bump.py --base "$BASE" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 515d5282..f83cd9f0 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -29,9 +29,12 @@ if printf '%s\n' "$STAGED" | grep -qE '(cheap-worker/fixtures/genre-rubrics/|\.a python3 "$ROOT/.agents/tools/rubric-sync-gate.py" || { echo '✘ rubric 双写对账未过'; fail=1; } fi -# ── 门 5:Prompt Registry 版本一致性 —— 触发 = 触及 prompts 契约(registry.yaml 或任一 prompt .md) ── +# ── 门 5:Prompt 契约双闸 —— 触发 = 触及 prompts 契约(registry.yaml 或任一 prompt .md) ── +# 闸 a = 版本一致性(check_registry:registry.yaml↔.md frontmatter 的 version 必须相等); +# 闸 b = 四道闸·闸0 版本升号(check_version_bump:改了 prompt 正文相对 HEAD 必须升 version)。 if printf '%s\n' "$STAGED" | grep -qE '^contracts/prompts/'; then python3 "$ROOT/contracts/prompts/check_registry.py" || { echo '✘ Prompt Registry 版本一致性未过'; fail=1; } + python3 "$ROOT/contracts/prompts/check_version_bump.py" || { echo '✘ Prompt 闸0 version 升号未过(改正文须升 version)'; fail=1; } fi if [ "$fail" -ne 0 ]; then diff --git a/contracts/prompts/check_version_bump.py b/contracts/prompts/check_version_bump.py new file mode 100755 index 00000000..e5cffe47 --- /dev/null +++ b/contracts/prompts/check_version_bump.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +check_version_bump.py — Prompt 四道闸 · 闸 0「version 升号强制」离线闸(段 A) + +防什么:改了一条 prompt 的正文(模板体或 frontmatter 实质字段)却没升 version, +会让「按 id@version 加载」「回归比对基线」全部失效——审计追溯里两个不同文本挂同一版本号, +线上换了行为却无人知晓。本门在每次提交/CI 时对比基线,逮住「正文变了、version 没升」并 exit 1 拦下。 + +与 check_registry.py 的分工(各管一摊、互不等待): + · check_registry.py —— 静态一致性:registry.yaml 的 version 必须与 .md frontmatter 的 version 相等。 + · check_version_bump.py(本脚本)—— 变更纪律:相对基线,prompt 正文若有实质改动,version 必须随之升。 + 两门叠加才完整:前者防「两处版本号写歪」,后者防「改了内容忘记升号」。 + +判定口径(明确、可解释): + 对每条 registry 登记的 prompt .md,取「基线版本」与「当前工作区版本」,各自算两样东西—— + ① 规范化正文 = 全文剔除 frontmatter 里的 version 行之后的内容(升版动作本身体现在 version 行, + 故比对时把它排除;version 行的行内说明注释也随之排除,改升版说明不强制再升号); + ② version 值 = frontmatter 顶层 version 字段(剥行内注释与引号)。 + 若「规范化正文变了,而 version 值没变」→ 违规(改了正文没升号)。 + 新增文件(基线不存在该路径)不判——首次入库无基线可比;被删除的文件不判(交 check_registry 报缺失)。 + +基线怎么取: + 默认基线 = HEAD(本地开发/提交前自查/pre-commit 场景:比「工作区当前内容」对「上次提交」)。 + CI 服务端可 --base 指定 push 前的提交(见 .gitea/workflows/contract-gates.yml)。 + 基线 ref 无效(如首次 push 的全 0 SHA)→ 打印说明并 exit 0(无可比基线不误拦,绝不假绿放行违规—— + 真无基线时本就没有「改了没升号」这回事)。 + +用法: + python3 contracts/prompts/check_version_bump.py # 基线 = HEAD + python3 contracts/prompts/check_version_bump.py --base + # exit 0 = 无违规(含无基线跳过);exit 1 = 有条目改了正文却没升 version + +零三方依赖(对齐 check_registry.py):只用标准库 + 调 git 命令。 +""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +# 本文件所在目录 = contracts/prompts/ +PROMPTS_DIR = Path(__file__).resolve().parent +REGISTRY_FILE = PROMPTS_DIR / "registry.yaml" + + +def _strip_inline_comment(value): + """剥行内注释(# 后非引号内的内容去掉),再剥成对外层引号。与 check_registry.py 同逻辑(避免三方依赖)。""" + out = [] + in_quote = None + for ch in value: + if in_quote: + if ch == in_quote: + in_quote = None + out.append(ch) + elif ch in ("'", '"'): + in_quote = ch + out.append(ch) + elif ch == '#': + break # 注释起点(不在引号内) + else: + out.append(ch) + s = "".join(out).strip() + if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'): + s = s[1:-1] + return s + + +def parse_registry_files(text): + """从 registry.yaml 文本解析出每条 prompt 的 (id, file) —— 只取本门需要的两字段。 + 解析口径与 check_registry.py 一致(最小 YAML 子集,无三方依赖)。""" + entries = [] + current = None + in_prompts = False + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if re.match(r'^prompts\s*:\s*(#.*)?$', line): + in_prompts = True + continue + if not in_prompts: + continue + m = re.match(r'^\s*-\s+id\s*:\s*(.+)$', line) + if m: + current = {'id': _strip_inline_comment(m.group(1))} + entries.append(current) + continue + m = re.match(r'^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$', line) + if m and current is not None: + current[m.group(1)] = _strip_inline_comment(m.group(2)) + continue + if re.match(r'^[A-Za-z_]', line): + in_prompts = False + current = None + return entries + + +def _frontmatter_bounds(text): + """返回 frontmatter 的 (起始行号=0, 闭合 --- 行号) 或 None(无合法 frontmatter)。""" + lines = text.split("\n") + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + return (0, i) + return None + + +def canonical_content(text): + """规范化正文:全文剔除 frontmatter 内的顶层 version 行后返回。 + 用于判定「除升版动作外是否有实质改动」——version 行(含其行内说明注释)被排除,其余逐字节保留。 + 无 frontmatter → 原样返回(整篇当正文比对)。""" + bounds = _frontmatter_bounds(text) + if bounds is None: + return text + _, close_idx = bounds + lines = text.split("\n") + kept = [] + for i, line in enumerate(lines): + # 仅在 frontmatter 范围内(1..close_idx-1)剔除顶层 version 行 + if 1 <= i < close_idx and re.match(r'^version\s*:', line): + continue + kept.append(line) + return "\n".join(kept) + + +def frontmatter_version(text): + """取 frontmatter 顶层 version 值(剥行内注释/引号);无则返回 None。""" + bounds = _frontmatter_bounds(text) + if bounds is None: + return None + _, close_idx = bounds + for line in text.split("\n")[1:close_idx]: + m = re.match(r'^version\s*:\s*(.+)$', line) + if m: + return _strip_inline_comment(m.group(1)) + return None + + +def git_repo_root(): + """仓库根目录(worktree 亦可);失败返回 None。""" + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=str(PROMPTS_DIR), capture_output=True, text=True, check=True, + ) + return Path(out.stdout.strip()) + except (subprocess.CalledProcessError, OSError): + return None + + +def git_show(root, ref, rel_path): + """git show : 的内容;路径在该 ref 不存在(新文件)或出错 → 返回 None。""" + try: + out = subprocess.run( + ["git", "show", f"{ref}:{rel_path}"], + cwd=str(root), capture_output=True, text=True, + ) + if out.returncode != 0: + return None # 该 ref 下无此路径(新增文件) + return out.stdout + except OSError: + return None + + +def git_ref_valid(root, ref): + """基线 ref 是否有效(可解析为一个 commit)。""" + try: + out = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + cwd=str(root), capture_output=True, text=True, + ) + return out.returncode == 0 + except OSError: + return False + + +def main(): + parser = argparse.ArgumentParser(description="Prompt 闸 0:改正文必升 version(离线版本闸)") + parser.add_argument("--base", default="HEAD", + help="对比基线 git ref(默认 HEAD;CI 传 push 前提交)") + args = parser.parse_args() + + root = git_repo_root() + if root is None: + print("[跳过] 非 git 仓库或 git 不可用,版本闸无基线可比(不拦)。") + sys.exit(0) + + if not git_ref_valid(root, args.base): + print(f"[跳过] 基线 ref 无效({args.base!r}),无可比基线,版本闸跳过(不误拦、不假绿)。") + sys.exit(0) + + try: + registry_text = REGISTRY_FILE.read_text(encoding="utf-8") + except OSError as e: + print(f"[错误] 无法读取 registry.yaml:{e}", file=sys.stderr) + sys.exit(1) + + entries = parse_registry_files(registry_text) + if not entries: + print("[警告] registry.yaml 解析出 0 条 prompt 条目(格式异常?),退出码 1。", file=sys.stderr) + sys.exit(1) + + violations = [] + checked = 0 + for entry in entries: + pid = entry.get('id', '(无 id)') + rel_file = (entry.get('file') or '').strip() + if not rel_file: + continue # 缺 file 字段交 check_registry 报,不在本门职责 + + cur_path = PROMPTS_DIR / rel_file + if not cur_path.exists(): + continue # 工作区文件被删/缺失,交 check_registry 报缺失,本门不判 + + rel_from_root = f"contracts/prompts/{rel_file}" + base_text = git_show(root, args.base, rel_from_root) + if base_text is None: + continue # 基线不存在该文件(新增),无基线可比,跳过 + + try: + cur_text = cur_path.read_text(encoding="utf-8") + except OSError as e: + violations.append(f" [{pid}] 读工作区文件失败:{e}({rel_file})") + continue + + checked += 1 + base_canon = canonical_content(base_text) + cur_canon = canonical_content(cur_text) + if base_canon == cur_canon: + continue # 正文无实质改动(或仅改了 version 行/升版说明),放行 + + base_ver = frontmatter_version(base_text) + cur_ver = frontmatter_version(cur_text) + if base_ver == cur_ver: + violations.append( + f" [{pid}] 正文已改但 version 未升(仍为 {cur_ver!r}):{rel_file}" + ) + + if violations: + print(f"[FAIL] Prompt 闸 0 未通过({len(violations)} 条改了正文却没升 version):") + for v in violations: + print(v) + print() + print("修复方式:改一条 prompt 的正文(模板体或 frontmatter 实质字段),必须同步升 version" + "(.md frontmatter + registry.yaml 两处,语义化版本号,改一字也要升)。") + sys.exit(1) + else: + print(f"[OK] Prompt 闸 0 通过(相对基线 {args.base} 比对了 {checked} 条已存在的 prompt," + "改了正文的都已升 version)。") + sys.exit(0) + + +if __name__ == "__main__": + main()