Files
gplan-2/tools/gdstyle_gate.py
T

233 lines
12 KiB
Python
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
# -*- coding: utf-8 -*-
"""gdstyle_gate.py — S13 第三方质量规则闸件(game/tests/static_scan.sh 调用)
跑 gdstyle(github.com/atelico/gdstyle,Rust 单二进制,版本钉 v0.4.0)质量规则扫描,
与存量基线 tools/gdstyle_baseline.txt 对账。规则口径见 tools/gdstyle.toml 头注(只开
quality/*+syntax/reserved-identifier,format/naming/order 全关)。扫描面=产品码三目录
(game/core、game/data、game/presentation,与 S9 类型闸同口径——probe/sim/tests 测量面
直构惯例不在面)。
基线键=稳定身份键(消息里的行数/分支数/变量数等计数漂移不再换键,存量债改小不假红):
函数级带数条目 键=文件|规则|函数名(函数名取自消息 function 'x' 段);
类级/文件级带数条目(class variables/public methods/file length)
键=文件|规则;
无数字条目 键=文件|规则|消息全文(消息本身稳定,保留第三段避免同文件同规则多条互吞)。
带数条目行格式「键 = 数值」,数值=消息剥掉 (max N) 段后首个整数;无数字条目裸键无数值。
判定:①键不在基线=新违规(NEW)红;②键在基线且双方有数值、当前数值 > 基线登记值=
恶化(WORSENED)红——独立于差集,防稳定键吞掉 104→300 行式恶化;③其余绿(改小/持平不红)。
逐条违规行带 FAIL 前缀——gate 09 的存量红指纹只取含 FAIL 的行,带前缀才能让「红项清单」
(哪几条、恶化到多少)进指纹:否则存量红期间的任何新增/进一步恶化都算同款红被放行。
阈值口径=gdstyle 默认值(tools/gdstyle.toml 不设数值阈值);基线只是欠账簿,登记值只准因
还债下降,禁抬高到现状(抬高=把既有代码当尺子,默认恶化)。
基线重生成=人工专用(--refresh,附新旧对账、骤降守卫与抬升守卫),禁在闸内自动重生成
(防清基线换绿);新键数较旧基线少逾 10% 拒绝写入(防扫面断裂静默缩基线换绿);任一键登记
值被抬高同样拒绝(防以现状为尺吞掉恶化)。两条守卫都须 --force 显式确认,--force 只在
refresh 路径有效。
二进制:本机固定路径 D:/code/gdstyle/gdstyle.exe(不入库不入包),GDSTYLE_BIN 环境变量覆盖;
版本与钉定版不符即红——升级工具须重测语法兼容性并人工重生成基线。
退出码:0=无基线外新违规且无恶化;1=有新违规/有恶化/二进制缺失或版本不符/扫描面为空/
输出不可解析/refresh 骤降或抬升拒写;2=用法错。
用法:python tools/gdstyle_gate.py # 闸判定
python tools/gdstyle_gate.py --refresh # 人工重生成基线(覆盖旧文件,附对账)
python tools/gdstyle_gate.py --refresh --force # 骤降/抬升守卫触发时显式确认写入
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CONFIG = ROOT / "tools" / "gdstyle.toml"
BASELINE = ROOT / "tools" / "gdstyle_baseline.txt"
SCAN_DIRS = ["game/core", "game/data", "game/presentation"]
PINNED_VERSION = "0.4.0"
# 二进制定位:GDSTYLE_BIN 环境变量优先;Windows 本机固定路径保底,macOS/Linux 走 /usr/local/bin
# (mac_setup.sh 自举件装钉定版 v0.4.0 darwin 包至此,远端 CI 与本机同判据)。
DEFAULT_BIN = ("D:/code/gdstyle/gdstyle.exe" if sys.platform == "win32"
else "/usr/local/bin/gdstyle")
MAX_REPORT = 50
MAX_VANISHED_REPORT = 20
SHRINK_GUARD = 0.9 # 新键数 < 旧键数 * 本系数=骤降,refresh 拒写须 --force
_FUNC_RE = re.compile(r"function '([^']+)'")
_MAX_RE = re.compile(r"\s*\(max \d+\)")
_INT_RE = re.compile(r"\d+")
def fail(msg: str) -> int:
print("[gdstyle_gate] FAIL — %s" % msg)
return 1
def build_entry(finding: dict):
"""finding dict -> (key, value|None)。数值=消息剥掉 (max N) 段后首个整数。"""
file_path = str(finding.get("file", "")).replace("\\", "/")
rule = str(finding.get("rule", ""))
msg = str(finding.get("message", ""))
m = _INT_RE.search(_MAX_RE.sub("", msg))
value = int(m.group()) if m else None
fm = _FUNC_RE.search(msg)
if fm:
key = "%s|%s|%s" % (file_path, rule, fm.group(1))
elif value is not None:
key = "%s|%s" % (file_path, rule)
else:
key = "%s|%s|%s" % (file_path, rule, msg)
return key, value
def collect_entries(findings) -> dict:
"""扫描结果 -> {键: 数值|None};同键多条取较大数值(恶化侧保守)。"""
entries = {}
for f in findings:
if not isinstance(f, dict) or "rule" not in f:
continue
key, value = build_entry(f)
if key in entries and entries[key] is not None and value is not None:
value = max(entries[key], value)
entries[key] = value
return entries
def parse_baseline() -> dict:
"""基线文件 -> {键: 数值|None}。行「键 = 数字」解析为带数条目,其余整行为裸键。"""
entries = {}
for line in BASELINE.read_text(encoding="utf-8").splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
key, value = s, None
if " = " in s:
k, _, v = s.rpartition(" = ")
if v.isdigit():
key, value = k, int(v)
entries[key] = value
return entries
def write_baseline(entries: dict) -> None:
lines = [
"# gdstyle 存量基线(S13)——键每行一条;只记基线不记豁免理由。",
"# 键形态(稳定身份键,计数漂移不换键):函数级带数条目=文件|规则|函数名;类级/文件级带数条目",
"# (class variables/public methods/file length)=文件|规则;无数字条目=文件|规则|消息全文(裸键)。",
"# 带数条目行格式「键 = 数值」,数值=消息剥掉 (max N) 段后首个整数;无数字条目裸键。",
"# 判定=键不在基线红(NEW)、同键数值较登记值恶化红(WORSENED)、改小/持平不红。",
"# 重生成=人工专用:python tools/gdstyle_gate.py --refresh(禁清基线换绿;改规则口径/工具升级须随批",
"# 重生成;新键数较旧基线少逾 10% 须加 --force 显式确认)。",
]
for key in sorted(entries):
value = entries[key]
lines.append("%s = %d" % (key, value) if value is not None else key)
BASELINE.write_text("\n".join(lines) + "\n", encoding="utf-8")
def run_gate() -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
args = sys.argv[1:]
refresh = "--refresh" in args
force = "--force" in args
if (force and not refresh) or any(a not in ("--refresh", "--force") for a in args):
print("[gdstyle_gate] 用法: python tools/gdstyle_gate.py [--refresh] [--force](--force 只在 refresh 路径有效)")
return 2
binary = os.environ.get("GDSTYLE_BIN", DEFAULT_BIN)
try:
ver = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=30)
except (FileNotFoundError, OSError):
return fail("gdstyle 二进制不可用:%s(本机固定路径,GDSTYLE_BIN 可覆盖;"
"v%s Windows 版见 github.com/atelico/gdstyle releases)" % (binary, PINNED_VERSION))
if PINNED_VERSION not in (ver.stdout or ""):
return fail("gdstyle 版本不符:钉定 v%s,实得 %r——升级工具须重测语法兼容性并人工重生成基线"
% (PINNED_VERSION, (ver.stdout or "").strip()))
face = [p for d in SCAN_DIRS for p in Path(ROOT, d).rglob("*.gd")]
if not face:
return fail("扫描面为空——%s 下零个 .gd(路径写错导致的静默空转)" % "、".join(SCAN_DIRS))
try:
proc = subprocess.run(
[binary, "check", "--format", "json", "--no-color", "-c", str(CONFIG)] + SCAN_DIRS,
capture_output=True, text=True, timeout=300, cwd=str(ROOT), encoding="utf-8",
)
except subprocess.TimeoutExpired:
return fail("gdstyle 扫描超时(300s)")
try:
findings = json.loads(proc.stdout or "[]")
except json.JSONDecodeError:
return fail("gdstyle 输出不可解析为 JSON(stderr 头部:%r)" % (proc.stderr or "")[:200])
current = collect_entries(findings)
if refresh:
old = parse_baseline() if BASELINE.exists() else None
if old is not None:
vanished = sorted(set(old) - set(current))
print("[gdstyle_gate] 对账:旧基线 %d 条,新扫描 %d 条,消失键 %d 条"
% (len(old), len(current), len(vanished)))
for k in vanished[:MAX_VANISHED_REPORT]:
print(" GONE " + k)
if len(vanished) > MAX_VANISHED_REPORT:
print(" ...(截断,共 %d 条)" % len(vanished))
if len(current) < len(old) * SHRINK_GUARD and not force:
return fail("新键数 %d 较旧基线 %d 少逾 10%%——疑扫面断裂静默缩基线换绿,拒绝写入;"
"确认缩基线正当(如删规则/缩扫描面)须加 --force 显式确认" % (len(current), len(old)))
raised = sorted(
(k, old[k], current[k]) for k in current
if k in old and old[k] is not None and current[k] is not None and current[k] > old[k]
)
if raised and not force:
for k, was, now in raised[:MAX_REPORT]:
print(" FAIL 抬高 %s = %d(登记 %d)" % (k, now, was))
if len(raised) > MAX_REPORT:
print(" ...(截断,共 %d 条)" % len(raised))
return fail("重生成会抬高 %d 条登记值——基线只准因还债下降,抬高=以现状为尺默认恶化;"
"确属阈值口径变更或经用户批准须加 --force 显式确认" % len(raised))
else:
print("[gdstyle_gate] 旧基线不存在,跳过对账(首次落位)")
write_baseline(current)
print("[gdstyle_gate] 基线已重生成:%s 条(%s)" % (len(current), BASELINE.name))
return 0
base = parse_baseline() if BASELINE.exists() else {}
if not base and not BASELINE.exists():
return fail("基线文件不存在:%s(首次落位跑 python tools/gdstyle_gate.py --refresh,须经用户批准)" % BASELINE.name)
new = sorted(k for k in current if k not in base)
worsened = sorted(
k for k in current
if k in base and current[k] is not None and base[k] is not None and current[k] > base[k]
)
print("[gdstyle_gate] 扫描面 %d 个 .gd;命中 %d 条,基线内 %d 条,基线外新违规 %d 条,较登记值恶化 %d 条"
% (len(face), len(current), len(set(current) & set(base)), len(new), len(worsened)))
red = False
if new:
for k in new[:MAX_REPORT]:
print(" FAIL 新增 " + k)
if len(new) > MAX_REPORT:
print(" FAIL ...(截断,共 %d 条)" % len(new))
red = True
if worsened:
for k in worsened[:MAX_REPORT]:
print(" FAIL 恶化 %s = %d(基线登记 %d)" % (k, current[k], base[k]))
if len(worsened) > MAX_REPORT:
print(" FAIL ...(截断,共 %d 条)" % len(worsened))
red = True
if red:
return fail("基线外新违规 %d 条/恶化 %d 条——新码须修到基线口径内,恶化须还债或经用户批准随批重生成基线"
% (len(new), len(worsened)))
print("[gdstyle_gate] ok")
return 0
if __name__ == "__main__":
sys.exit(run_gate())