#!/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 不设数值阈值);基线只是欠账簿,登记值只准因 还债下降,禁抬高到现状(抬高=把既有代码当尺子,默认恶化)。 基线维护=人工手动编辑基线文件(本闸无任何重写基线的入口——防清基线换绿;增删行与改登记值 都是人工动作,须用户批准;登记值只准因还债下降,禁抬高到现状)。 二进制:本机固定路径 D:/code/gdstyle/gdstyle.exe(不入库不入包),GDSTYLE_BIN 环境变量覆盖; 版本与钉定版不符即红——升级工具须重测语法兼容性并人工修订基线。 退出码:0=无基线外新违规且无恶化;1=有新违规/有恶化/二进制缺失或版本不符/扫描面为空/ 输出不可解析;2=用法错。 用法:python tools/gdstyle_gate.py # 闸判定 """ 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 _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 run_gate() -> int: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") args = sys.argv[1:] if args: print("[gdstyle_gate] 用法: python tools/gdstyle_gate.py(无参数;基线维护=人工手动编辑基线文件)") 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) base = parse_baseline() if BASELINE.exists() else {} if not base and not BASELINE.exists(): return fail("基线文件不存在:%s(首次落位=人工创建基线文件、格式见文件头注,须经用户批准)" % 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())