#!/usr/bin/env python3 # -*- coding: utf-8 -*- r"""extract_checks.py — 断言静态提取器(验证集清单生成与出口对账) 机器提取宿主脚本的全部静态断言调用点(每个调用点记录:所在文件、所在函数、 行号、标签字符串字面量原文、cond 表达式原文),提供两种模式: 用法: python tools/extract_checks.py --extract [--src <源文件>...] [--out <清单文件>] [--force] 生成验证集清单(已存在时拒写,除非 --force——冻结参照物防误覆) python tools/extract_checks.py --diff <清单文件> [--src <源文件>...] 对当前源码再提取,与清单逐条比对——批 B 出口对账唯一工具 python tools/extract_checks.py --list-funcs [--src <源文件>...] 只打印函数清单与各函数条数(编账本辅助,不写文件) 断言源(--src 可多个;缺省=三宿主遍历): game/tests/unit/**/*.gd + game/tests/integration/**/*.gd + game/tests/system_test.gd 批 B 出口对账用显式模式(--src 列全本批宿主文件)——缺省遍历含既有套件的 Kit.check 断言,对账时以「新增」出现(非迁移面),出口判定以显式模式为准。 提取口径: - 静态断言调用点,三通道都认:smoke/系统宿主的 check(cond, label)(cond 在前)、 单元/集成宿主的 Kit.check("label", cond)(点前缀=标签在前)、静默收集通道 Kit.check_quiet(cond, label)(cond 在前,条目 quiet 列记 channel:check_quiet); `_check(` 等同名前缀调用非标准通道,不取。 - 调用点可跨行:从调用名起括号配平归集完整实参文本(() [] {} 三种括号都计深度); 字符串字面量内的括号/逗号/井号、行尾注释、转义序列均不参与结构判定(码位掩码单趟预扫)。 - cond 摘要=cond 原文跨行折叠为单行(连续空白并一空格);标签原文=标签实参里的字符串 字面量逐字保真(含引号与转义序列原样,禁改写);标签实参若带格式化/拼接后缀 (`"..." % [...]`),后缀折叠后以「↩」接排在标签列。 - md 表格单元格内 `|` 转义为 `\|`、`\` 转义为 `\\`(源码串内无这两序列的歧义形态,读回可还原)。 --diff 对账规则:只比对「标签+cond+计数」三要素(合同 §五出口机检);行号随迁移漂移、 序号重排、白话释义/处置列由维护回填,均不参与比对。对不上的条目分三类报告: 消失(清单有现码无)、新增(现码有清单无)、成对字面变更(cond 同标签异=标签变更; 标签同 cond 异=cond 变更——一条「消失」与一条「新增」同因一字之改时按成对呈现)。 退出码:0=全对齐;1=有差异;2=用法错或文件不可读。 """ import argparse import bisect import re import subprocess import sys from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parent.parent # 断言源缺省=三宿主遍历:unit/**+integration/** 两目录递归 + 系统宿主单文件。 # 遍历面含既有套件的 Kit.check 断言(非本批迁移面,对账呈「新增」)——出口对账用 --src 显式列宿主。 DEFAULT_TREES = ( ROOT / "game" / "tests" / "unit", ROOT / "game" / "tests" / "integration", ROOT / "game" / "tests" / "system_test.gd", ) DEFAULT_OUT = ROOT / "game" / "tests" / "验证集清单.md" def default_sources(): """三宿主遍历:两目录递归 *.gd + 系统宿主单文件,路径排序定序。""" files = [] for p in DEFAULT_TREES: if p.is_file(): files.append(p) elif p.is_dir(): files.extend(sorted(p.rglob("*.gd"))) return sorted(files) CALL_RE = re.compile(r"check(?:_quiet)?\(") DEF_RE = re.compile(r"^\s*(static\s+)?func\s+check(?:_quiet)?\s*\(") FUNC_RE = re.compile(r"^(static\s+)?func\s+([A-Za-z_][A-Za-z0-9_]*)") LITERAL_RE = re.compile(r'"((?:[^"\\]|\\.)*)"') OPENERS = "([{" CLOSERS = ")]}" CLOSER_OF = {")": "(", "]": "[", "}": "{"} def fold(text: str) -> str: """跨行折叠:连续空白(含换行/制表/回车)并为一空格。对账归一口径。""" return re.sub(r"\s+", " ", text).strip() def esc_cell(text: str) -> str: return text.replace("\\", "\\\\").replace("|", "\\|") def unesc_cell(text: str) -> str: """esc_cell 的逆。源码串内无 `\\|`/`\\\\` 歧义形态(提取口径节),还原无歧义。""" out = [] i = 0 while i < len(text): if text[i] == "\\" and i + 1 < len(text): out.append(text[i + 1]) i += 2 else: out.append(text[i]) i += 1 return "".join(out) def code_mask(joined: str) -> bytearray: """单趟预扫:1=码位(非字符串内部、非注释)。字符串与行尾注释位置置 0。""" mask = bytearray(len(joined)) in_str = False i = 0 n = len(joined) while i < n: ch = joined[i] if in_str: mask[i] = 0 if ch == "\\" and i + 1 < n: mask[i + 1] = 0 i += 2 continue if ch == '"': in_str = False i += 1 continue if ch == '"': in_str = True mask[i] = 0 i += 1 continue if ch == "#": nl = joined.find("\n", i) end = n if nl == -1 else nl for j in range(i, end): mask[j] = 0 i = end continue mask[i] = 1 i += 1 return mask def func_table(lines): """顶层函数表:[(name, start_line_1based)]。lambda 归宿主函数。""" funcs = [] for i, line in enumerate(lines, 1): m = FUNC_RE.match(line) if m: funcs.append((m.group(2), i)) return funcs def func_at(funcs, lineno): name = "" for fn, start in funcs: if start <= lineno: name = fn else: break return name def split_args(body): """按括号配平拆实参(顶层逗号分隔)。body=check( 与最外层 ) 之间的文本(换行已并空格)。""" args = [] depth = {"(": 0, "[": 0, "{": 0} cur = [] in_str = False i = 0 while i < len(body): ch = body[i] if in_str: if ch == "\\" and i + 1 < len(body): cur.append(body[i]) cur.append(body[i + 1]) # 转义序列原样入账 i += 2 continue if ch == '"': in_str = False cur.append(ch) elif ch == '"': in_str = True cur.append(ch) elif ch in depth: depth[ch] += 1 cur.append(ch) elif ch in CLOSERS: depth[CLOSER_OF[ch]] -= 1 cur.append(ch) elif ch == "," and depth["("] == 0 and depth["["] == 0 and depth["{"] == 0: args.append("".join(cur)) cur = [] else: cur.append(ch) i += 1 args.append("".join(cur)) return args def collect_call(joined, start, call_len): """从调用名起点起括号配平归集调用。返回 (body, end_pos);不配平返回 None。""" i = start + call_len depth = 1 in_str = False parts = [] n = len(joined) while i < n: ch = joined[i] if in_str: if ch == "\\": parts.append(joined[i:i + 2]) i += 2 continue if ch == '"': in_str = False parts.append(ch) elif ch == '"': in_str = True parts.append(ch) elif ch in OPENERS: depth += 1 parts.append(ch) elif ch in CLOSERS: depth -= 1 if depth == 0: return "".join(parts), i parts.append(ch) elif ch == "#": nl = joined.find("\n", i) i = n if nl == -1 else nl # 行尾注释不进 body(并一空格) parts.append(" ") continue else: parts.append(ch if ch != "\n" else " ") i += 1 return None, i def extract(src_path: Path): """提取单文件全部静态断言调用点(check/check_quiet)。返回(条目列表, 函数表),条目保持源码顺序。""" src = src_path.read_text(encoding="utf-8") lines = src.split("\n") funcs = func_table(lines) offsets = [] total = 0 for line in lines: offsets.append(total) total += len(line) + 1 joined = "\n".join(lines) mask = code_mask(joined) def line_of(pos): i = bisect.bisect_right(offsets, pos) - 1 return i + 1 entries = [] for m in CALL_RE.finditer(joined): if not mask[m.start()]: continue # 字符串字面量内 / 注释里的调用名字样 ln = line_of(m.start()) if DEF_RE.match(lines[ln - 1]): continue # func check / func check_quiet 定义行 prev = joined[m.start() - 1] if m.start() > 0 else "" if prev and (prev.isalnum() or prev == "_"): continue # `_check(` 等同名前缀——非标准通道 is_quiet = m.group(0).startswith("check_quiet") if is_quiet: label_first = False # Kit.check_quiet(cond, label)——cond 在前 else: label_first = prev == "." # Kit.check("label", cond)——点前缀=标签在前 body, end_pos = collect_call(joined, m.start(), len(m.group(0))) if body is None: raise SystemExit("[extract_checks] FAIL:%s 行 %d 起的断言调用括号不配平到文件尾——先查解析缺口" % (src_path.name, ln)) args = split_args(body) if len(args) not in (2, 3): raise SystemExit( "[extract_checks] FAIL:%s 行 %d 的断言调用拆出 %d 个实参(预期 2/3)——解析缺口,先查再跑" % (src_path.name, ln, len(args))) if label_first: label_expr, cond = args[0], args[1] else: cond, label_expr = args[0], args[1] quiet = "channel:check_quiet" if is_quiet else (args[2] if len(args) == 3 else "") lm = LITERAL_RE.search(label_expr) if lm is None: continue # 标签实参无字面量(wrapper 直传变量——既有套件先例 reward_overlay_test _req) # 不取:无可指纹化面;冻结面对账不受影响(字面量损坏会以「消失」呈现) entries.append({ "line": ln, "end_line": line_of(min(end_pos, len(joined) - 1)), "func": func_at(funcs, ln), "label_literal": lm.group(0), # 含引号,原文逐字 "label_suffix": label_expr[lm.end():], # 格式化/拼接后缀(通常为空或 % ...) "cond": cond, "quiet": quiet, }) return entries, funcs def extract_all(src_paths): """多文件提取合并。返回(条目列表, {相对路径: 函数表});条目附加 src=相对路径。""" entries = [] funcs_by_src = {} for p in src_paths: es, funcs = extract(p) rel = p.relative_to(ROOT).as_posix() if p.is_relative_to(ROOT) else p.as_posix() for e in es: e["src"] = rel entries.extend(es) funcs_by_src[rel] = funcs return entries, funcs_by_src def label_cell(e): """标签列:字面量 ↩ 后缀(后缀空则只有字面量)。""" sfx = fold(e["label_suffix"]) return e["label_literal"] + (" ↩ " + sfx if sfx else "") def entry_key(e): """对账指纹=合同 §五三要素:cond 折叠 + 标签字面量(逐字)+ 后缀折叠。 quiet 不入指纹——它是 smoke 通道的打印档位(只影响 PASS 行打印,计数/红绿同口径), Kit 通道无此参,迁移时按接线注记落通道转换;quiet 列保留展示,不参与对账。""" return (fold(e["cond"]), e["label_literal"], fold(e["label_suffix"])) def pad3(n): return "S%02d" % n if n < 100 else "S%03d" % n def base_commit(): try: r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8") if r.returncode == 0: return r.stdout.strip() except OSError: pass return "" def render_list(entries, funcs_by_src, src_names, carry=None) -> str: """生成验证集清单。冻结列=机器提取;「白话释义/处置」两列:承接命中行用旧清单回填, 其余落(待填)由维护回填(回填不动冻结列)。多源按(源文件, 函数)分章——跨文件同名 函数不并章;章头行号=该源文件内行号。头注全部由当次数据生成,禁批次专名与写死计数。""" carry = carry or {} out = [] out.append("# 验证集清单(断言快照·冻结)") out.append("") out.append("> 生成日期:%s · 基点 commit:`%s` · 源:%s" % (date.today().isoformat(), base_commit(), "、".join(src_names))) out.append("> 提取口径:静态 `check(`/`check_quiet(` 调用点,括号配平归集(可跨行);缺省源=三宿主遍历") out.append("> (unit/**+integration/**+system_test.gd)。本基点实测 %d 个静态调用点;行级" % len(entries)) out.append("> `grep -c \"check(\"` 是另一口径(含 `func check(` 定义行与注释行),两口径各自浮动,") out.append("> 禁互相换算(tests/README「断言计数双口径」)。运行时计数大于静态计数=循环展开条目。") out.append("> 生成命令:`python tools/extract_checks.py --extract`(可复现;重新生成须 --force;") out.append("> 承接旧清单回填列须 --carry-from <旧清单>)。") out.append("> 冻结纪律:每条的「标签原文」「cond 摘要」「quiet」三列为机器提取逐字保真,禁任何改写;") out.append("> cond 摘要=原文跨行折叠单行(连续空白并一空格),单元格内 `|`→`\\|`、`\\`→`\\\\` 转义,读回还原。") out.append("> 「白话释义」「处置」两列由维护回填(未承接行为(待填)),不属冻结面。") out.append("> 对账:`python tools/extract_checks.py --diff game/tests/验证集清单.md`(缺省三宿主源)") out.append("> ——标签/cond/计数三对齐才算同步完。维护义务:断言新增/删除/改判必须同步本清单") out.append("> (test-conventions §8.3 维护锚)。") out.append("") seq = 0 by_gf = [] for e in entries: key = (e["src"], e["func"]) if by_gf and by_gf[-1][0] == key: by_gf[-1][1].append(e) else: by_gf.append((key, [e])) for idx, ((src, fn), es) in enumerate(by_gf, 1): start, end = es[0]["line"], es[-1]["end_line"] out.append("## %s %s(行 %d–%d · %d 条 · 源 %s · 去向:待分类回填)" % (pad3(idx), fn, start, end, len(es), src)) out.append("") out.append("| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet |") out.append("|---|---|---|---|---|---|---|") for e in es: seq += 1 hit = carry.get((fold(e["cond"]), e["label_literal"], fold(e["label_suffix"]))) disp = esc_cell(hit[0]) if hit else "(待填)" act = esc_cell(hit[1]) if hit else "" out.append("| %d | %d | %s | %s | %s | %s | %s |" % ( seq, e["line"], esc_cell(label_cell(e)), esc_cell(fold(e["cond"])), disp, act, esc_cell(fold(e["quiet"])))) out.append("") out.append("---") out.append("全源共 %d 章、%d 条(静态调用点口径)。" % (len(by_gf), seq)) return "\n".join(out) + "\n" CELL_SPLIT_RE = re.compile(r"(? int: cur, _funcs = extract_all(src_paths) frozen = parse_list(list_path) cur_keys = {} for e in cur: cur_keys.setdefault(entry_key(e), []).append(e) frozen_keys = {} for e in frozen: frozen_keys.setdefault(entry_key(e), []).append(e) missing = [k for k in frozen_keys if k not in cur_keys] added = [k for k in cur_keys if k not in frozen_keys] # 成对归类:cond 同→标签变更;标签同→cond 变更 label_changes, cond_changes = [], [] used_added = set() rest_missing = [] for k in missing: cond, lit, sfx = k pair = next((ka for ka in added if ka not in used_added and ka[0] == cond), None) if pair is not None: used_added.add(pair) label_changes.append((k, pair)) continue pair = next((ka for ka in added if ka not in used_added and ka[1] == lit and ka[2] == sfx), None) if pair is not None: used_added.add(pair) cond_changes.append((k, pair)) continue rest_missing.append(k) rest_added = [k for k in added if k not in used_added] ok = True print("[extract_checks] 对账:%s vs 现提取 %d 个源文件" % (list_path.name, len(src_paths))) print(" 清单条数=%d 现提取条数=%d %s" % ( len(frozen), len(cur), "(计数对齐)" if len(frozen) == len(cur) else "(计数不齐)")) def show(k, tag, q=""): cond, lit, sfx = k s = " %s cond=%s label=%s" % (tag, _short(cond), _short(lit)) if sfx: s += " suffix=%s" % _short(fold(sfx)) if q: s += " quiet=%s" % fold(q) print(s) if label_changes: ok = False print(" 标签字面变更 %d 对(cond 同、标签异):" % len(label_changes)) for old, new in label_changes: show(old, "-", frozen_keys[old][0].get("quiet", "")) show(new, "+", cur_keys[new][0].get("quiet", "")) if cond_changes: ok = False print(" cond 字面变更 %d 对(标签同、cond 异):" % len(cond_changes)) for old, new in cond_changes: show(old, "-", frozen_keys[old][0].get("quiet", "")) show(new, "+", cur_keys[new][0].get("quiet", "")) if rest_missing: ok = False print(" 消失 %d 条(清单有、现码无):" % len(rest_missing)) for k in rest_missing: print(" - [%s]" % frozen_keys[k][0].get("section", "?"), end="") show(k, "", frozen_keys[k][0].get("quiet", "")) if rest_added: ok = False print(" 新增 %d 条(现码有、清单无):" % len(rest_added)) for k in rest_added: es = cur_keys[k][0] print(" + [src %s func %s @%d]" % (es["src"], es["func"], es["line"]), end="") show(k, "", es.get("quiet", "")) if ok: print("[extract_checks] PASS——标签/cond/计数三对齐,零差异") return 0 print("[extract_checks] FAIL——见上差异清单") return 1 def list_funcs(entries, funcs_by_src): for src, funcs in funcs_by_src.items(): by_func = [] for e in entries: if e["src"] != src: continue if by_func and by_func[-1][0] == e["func"]: by_func[-1][1] = by_func[-1][1] + 1 else: by_func.append([e["func"], 1]) print("=== %s ===" % src) checked = {f for f, _ in by_func} for fn, n in by_func: print("%s\t%d" % (fn, n)) print("--- 无断言函数(helper,随套迁移)---") for fn, ln in funcs: if fn not in checked: print("%s\t@%d" % (fn, ln)) def resolve_sources(src_args): """--src 列表 → Path 列表(相对路径按仓库根解析);缺省=三宿主遍历。任一文件不可读即 None。""" if src_args: paths = [Path(s) if Path(s).is_absolute() else ROOT / s for s in src_args] else: paths = default_sources() if not paths: print("[extract_checks] FAIL:断言源为空(三宿主遍历无命中?)") return None for p in paths: if not p.is_file(): print("[extract_checks] FAIL:源文件不可读:%s" % p) return None return paths def main() -> int: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") ap = argparse.ArgumentParser(description="断言静态提取器(清单生成/对账)") ap.add_argument("--extract", action="store_true", help="生成验证集清单") ap.add_argument("--diff", metavar="清单文件", help="与清单逐条对账(批 B 出口机检)") ap.add_argument("--list-funcs", action="store_true", help="打印函数清单与条数(编账本辅助)") ap.add_argument("--src", nargs="+", default=None, help="断言源文件(可多个;缺省=三宿主遍历 unit/**+integration/**+system_test.gd)") ap.add_argument("--out", default=str(DEFAULT_OUT), help="--extract 输出路径") ap.add_argument("--force", action="store_true", help="输出文件已存在时允许覆盖") ap.add_argument("--carry-from", default=None, metavar="旧清单", help="--extract 时按指纹承接旧清单「白话释义/处置」两列(旧宿主文件名映射为新名)") args = ap.parse_args() if sum(bool(x) for x in (args.extract, args.diff, args.list_funcs)) != 1: print("[extract_checks] 用法:--extract / --diff <清单文件> / --list-funcs 三选一") return 2 src_paths = resolve_sources(args.src) if src_paths is None: return 2 if args.extract: out = Path(args.out) if out.exists() and not args.force: print("[extract_checks] FAIL:清单已存在(%s)——冻结参照物防误覆,重生成须加 --force" % out) return 2 carry = {} if args.carry_from: cf = Path(args.carry_from) if not cf.is_file(): print("[extract_checks] FAIL:--carry-from 旧清单不可读:%s" % cf) return 2 old_rows = parse_list(cf) carry = carry_cells(old_rows) print("[extract_checks] 旧清单 %d 行 → 承接指纹 %d 个" % (len(old_rows), len(carry))) entries, funcs_by_src = extract_all(src_paths) listing = render_list(entries, funcs_by_src, [p.name for p in src_paths], carry) with open(str(out), "w", encoding="utf-8", newline="\n") as fh: fh.write(listing) hitn = sum(1 for e in entries if (fold(e["cond"]), e["label_literal"], fold(e["label_suffix"])) in carry) print("[extract_checks] 已生成 %s(%d 个源文件、%d 个调用点、承接回填 %d 行)" % (out, len(src_paths), len(entries), hitn)) return 0 if args.diff: lp = Path(args.diff) if not lp.is_file(): print("[extract_checks] FAIL:清单不可读:%s" % lp) return 2 return diff_report(lp, src_paths) entries, funcs_by_src = extract_all(src_paths) list_funcs(entries, funcs_by_src) return 0 if __name__ == "__main__": sys.exit(main())