#!/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" DEFAULT_BIN = "D:/code/gdstyle/gdstyle.exe" 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())