a3a1319a11
一次性把本地与 CI 的门禁收成一条命令:make verify = python3 scripts/ci_checks.py。 两边跑的是同一份逻辑,不再各写一套步骤(两边各写一套的结局是迟早只改一边)。 14 项必需检查:identity(检出 == 事件完整 SHA,只在 CI 必需)、commits、deps-install、 format、static-go、static-web、build-go、build-web、tests-go、tests-web、contracts、 security-secrets、security-deps、manifests-locked。make list 打印清单。 三条判据写死在代码里: - 失败即停,但剩下的记 not_run 并如实写进报告;aggregate() 只认 success, 未运行/被跳过都不算通过(不适用要在清单里声明 required_in,不许运行时悄悄 skip)。 - 工具缺失、依赖没按锁装、输出解析不了 ⇒ 失败,不当成"检查通过"。 - 测试必须真跑:go test 加 -count=1 并盘点通过/跳过数;前端读 vitest 的 JSON 报告, 空套件不算通过。跳过原因会打进报告(当前 72 个集成用例因缺 TEST_DATABASE_URL 跳过)。 新增四个自带反向自检的脚本(--self-test 故意弄坏必须红): scripts/check_format.py(Go 判输出而非退出码 + 前端只能减的基线)、 scripts/check_secrets.py(只打印位置与类别,绝不打印值)、 scripts/check_deps_vuln.py(govulncheck 锁 v1.1.4 + npm audit 分级 + 到期例外)、 scripts/ci_checks.py(门禁 + 汇总)。 验证(本机实跑,全部为真实退出码): make verify → 总体 success(档案 local),identity=skipped 已在清单里声明为 CI 必需; tests-go 执行 31 个包、通过 549 / 失败 0 / 跳过 72;tests-web 106 个套件、191 个用例全过; security-deps:govulncheck v1.1.4 0 命中、npm audit 生产与全量均 0。
158 lines
7.5 KiB
Python
158 lines
7.5 KiB
Python
#!/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:])) |