#!/usr/bin/env python3 """格式门禁:Go(gofmt)+ 前端(prettier)。 判据(两条独立的账): · Go :`gofmt -l` 对**被跟踪的 .go 文件**输出必须为空 —— 一条都不许欠。 · 前端:`prettier --list-different` 的输出必须**完全落在基线里**(`web/.prettier-baseline.json`)。 基线是**一次性账面债**,只能减不能增: · 出现基线之外的文件 ⇒ 红(新写的代码必须格式化过); · 基线里的文件已格式化 ⇒ 提示删条目(不判红); · 基线到期(review_by)⇒ 红 —— 要么一次清干净、要么由人重新批准,**不许无声续命**。 为什么前端不一刀切全量格式化:本仓有一部分是刻意压行的紧凑写法,一次性重排约 2 万行, 那会把"接入 CI"变成"重写前端"。所以先用**列明 + 只能减 + 到期**的基线接住存量, 把增量卡住;全量清理留作独立提交(由人决定何时做)。 用法: python3 scripts/check_format.py python3 scripts/check_format.py --self-test # 只跑判定逻辑的反向自检 python3 scripts/check_format.py --web-only # 只查前端(调试用,不算全绿) """ from __future__ import annotations import argparse import datetime as dt import json import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent WEB = ROOT / "web" BASELINE = WEB / ".prettier-baseline.json" def compare(unformatted: list[str], baseline: dict, today: dt.date) -> tuple[list[str], list[str]]: """纯判定:返回 (问题, 提示)。--self-test 直接打这个函数。""" problems: list[str] = [] notes: list[str] = [] listed = baseline.get("unformatted") if not isinstance(listed, list): return [f"{BASELINE.name} 里没有 unformatted 列表 —— 基线坏掉时不能静默放行"], notes review_by = baseline.get("review_by") try: due = dt.date.fromisoformat(str(review_by)) except (TypeError, ValueError): problems.append(f"{BASELINE.name} 的 review_by 不是 YYYY-MM-DD —— 没有到期日的基线会永久留在仓库里") due = None if due and today > due: problems.append(f"前端格式债基线已于 {due} 到期:要么 `npm --prefix web run format` 一次清干净," f"要么由人重新批准并更新 review_by(基线不许无声续命)") known, now = set(listed), set(unformatted) for f in sorted(now - known): problems.append(f"新出现未格式化的文件:{f} —— 跑 `npm --prefix web run format` 再提交") for f in sorted(known - now): notes.append(f"基线里的 {f} 已经格式化,可以删掉这条") notes.append(f"存量格式债:基线 {len(known)} 个文件,实际未格式化 {len(now)} 个") return problems, notes def self_test() -> int: today = dt.date(2026, 10, 11) good = {"review_by": "2026-12-31", "unformatted": ["a.ts", "b.ts"]} cases = [ ("存量文件仍欠 = 只提示不红", ["a.ts", "b.ts"], good, 0), ("出现基线外的新欠账 = 红", ["a.ts", "c.ts"], good, 1), ("基线里已修好的条目 = 只提示不红", ["a.ts"], good, 0), ("基线到期 = 红", ["a.ts", "b.ts"], {**good, "review_by": "2026-01-01"}, 1), ("基线没有 unformatted = 红", ["a.ts"], {"review_by": "2026-12-31"}, 1), ("基线没有到期日 = 红", ["a.ts"], {"unformatted": ["a.ts"]}, 1), ] problems: list[str] = [] for name, unformatted, baseline, want in cases: got, _ = compare(unformatted, baseline, today) if len(got) != want: problems.append(f"{name}:期望 {want} 个问题,实际 {len(got)} 个 {got}") if problems: print("❌ 格式门禁自检失败:", file=sys.stderr) for p in problems: print(f" · {p}", file=sys.stderr) return 1 print(f"✅ 格式门禁自检通过:{len(cases)} 个判定分支全部符合预期") return 0 def check_go() -> tuple[bool, list[str]]: files = subprocess.run(["git", "ls-files", "*.go"], cwd=ROOT, capture_output=True, text=True).stdout.split() if not files: return True, ["没有 .go 文件,跳过(本仓不是 Go 仓时这里应当为空)"] gofmt = shutil.which("gofmt") or "/opt/citools/go/bin/gofmt" if not (shutil.which("gofmt") or Path(gofmt).exists()): return False, ["找不到 gofmt —— 工具缺失 ⇒ 失败,不当成'格式没问题'"] p = subprocess.run([gofmt, "-l", *files], cwd=ROOT, capture_output=True, text=True) # ⚠️ gofmt -l 列出文件时**退出码仍是 0** —— 只看退出码就是永远的假绿。 bad = [l for l in p.stdout.splitlines() if l.strip()] if p.returncode != 0: return False, [f"gofmt 执行失败(exit={p.returncode}):{p.stderr[:300]}"] if bad: return False, [f"{f} 未格式化" for f in bad] + ["跑 `gofmt -w <文件>` 再提交"] return True, [f"gofmt 干净({len(files)} 个文件)"] def check_web() -> tuple[bool, list[str]]: if not (WEB / "package-lock.json").is_file(): return False, ["web/package-lock.json 不存在 ⇒ 依赖没按锁文件装,格式门禁不可信"] prettier = WEB / "node_modules" / ".bin" / "prettier" if not prettier.exists(): return False, ["web/node_modules 里没有 prettier —— 先 `npm --prefix web ci`(工具缺失 ⇒ 失败)"] p = subprocess.run([str(prettier), "--list-different", "."], cwd=WEB, capture_output=True, text=True) if p.returncode not in (0, 1): return False, [f"prettier 执行失败(exit={p.returncode}):{(p.stderr or p.stdout)[:300]}"] unformatted = [l.strip() for l in p.stdout.splitlines() if l.strip()] if not BASELINE.is_file(): return False, [f"找不到 {BASELINE.relative_to(ROOT)} —— 没有基线就不能判断'新欠账'"] try: baseline = json.loads(BASELINE.read_text(encoding="utf-8")) except json.JSONDecodeError as e: return False, [f"{BASELINE.name} 解析失败:{e}"] problems, notes = compare(unformatted, baseline, dt.date.today()) for n in notes: print(f" · {n}") return not problems, problems def main(argv: list[str]) -> int: ap = argparse.ArgumentParser() ap.add_argument("--self-test", action="store_true") ap.add_argument("--web-only", action="store_true", help="只查前端(调试用,不算全绿)") args = ap.parse_args(argv) if args.self_test: return self_test() ok = True print("── 格式检查 ──") if not args.web_only: go_ok, go_msgs = check_go() print(" Go(gofmt):" + ("✅" if go_ok else "❌")) for m in go_msgs: print((" " if go_ok else " ❌ ") + m, file=sys.stdout if go_ok else sys.stderr) ok = ok and go_ok if not go_ok: print(" ⇒ 后续检查继续跑(格式债不阻塞其它结论,但总体已经不可能全绿)") web_ok, web_msgs = check_web() print(" 前端(prettier):" + ("✅" if web_ok else "❌")) for m in web_msgs: print((" " if web_ok else " ❌ ") + m, file=sys.stdout if web_ok else sys.stderr) ok = ok and web_ok if args.web_only: print(" ⚠️ 只跑了前端(--web-only):**不算全绿**") print("✅ 格式检查通过" if ok else "❌ 格式检查未通过") return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))