games-development-ai/contracts/prompts/check_version_bump.py
lili f1676a1d49 feat(prompts): 四道闸CI接线段A——离线版本升号闸 check_version_bump + 挂 pre-commit/contract-gates
闸0「改 prompt 正文必升 version」落成独立零依赖脚本(与 check_registry 同风格、各管一摊):
- 相对基线(默认 HEAD;CI 传 push 前提交)剔除 frontmatter version 行后比规范化正文——正文有实质改动而 version 未变即拒;
- 新增文件无基线不判、删除文件交 check_registry 报缺失;基线 ref 无效则跳过不误拦、不假绿;
- 挂 .githooks/pre-commit 门5(prompts 路径条件触发,基线 HEAD)+ .gitea/workflows/contract-gates.yml 服务端(checkout fetch-depth:0 供比对 push 前提交)。

自测:改 safety 正文不升版→FAIL exit1、同步升版→OK exit0、git checkout 恢复干净。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:58:57 -07:00

257 lines
10 KiB
Python
Executable File
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
"""
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 <ref> 指定 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 <git-ref>
# 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>:<rel_path> 的内容;路径在该 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()