tools/extract_checks.py:smoke_all.gd 静态 check( 调用点提取器(括号配平归集可跨行调用, 码位掩码排字符串/注释内字样)——--extract 生成清单(已存在拒写防误覆)、--diff 逐条对账 (消失/新增/成对字面变更三类报告,批 B 出口机检唯一工具)、--list-funcs 编账辅助。 基点实测调用点 1065;行级 grep=1067 系另一口径(多 2 行=func check 定义行+一处注释行), 清单头注已写明。对刚生成清单 --diff 自测零差异;释义/处置列回填后复跑仍零差异。 game/tests/验证集清单.md(冻结参照物·常驻):77 章 1065 条,标签原文与 cond 摘要逐字 保真(cond 跨行折叠单行口径),每条人工白话释义+处置列(去向)已回填;章节含行段/条数/ 去向/施工组。过 static_scan S11(205 文件零命中,唯一退役词字面标签与「去重闸」同行被 既有豁免覆盖,未新增豁免条款)。 reports/批B-断言迁移账本-2026-10-22.md:77 套分类账(系统 6 套 49 条/集成 1 套 14 条/ 单元 70 套 1002 条)+12 施工组+套级变异策略+宿主落位计划(11 个新宿主预定路径与 run_all.gd manifest/system_test.gd 挂载方式,本波零建文件)+歧义套 8 项留主会话裁决 +引用普查附录(合同在案必改点逐条核实+合同外新发现 18 类,含产品码 tune_sim_controller.gd preload smoke 库件一处)。 smoke_all.gd 零改动(git diff 空);三个去向既有测试文件零改动;本波零游戏测试。
495 lines
19 KiB
Python
495 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
r"""extract_checks.py — smoke_all.gd 断言静态提取器(验证集清单生成与出口对账)
|
||
|
||
机器提取 game/smoke/smoke_all.gd 的全部静态 check( 调用点(每个调用点记录:所在函数、
|
||
行号、标签字符串字面量原文、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 <源文件>]
|
||
只打印函数清单与各函数条数(编账本辅助,不写文件)
|
||
|
||
提取口径:
|
||
- 静态 check( 调用点。与行级 grep(grep -c "check(")的差:grep 数含 func check 定义行
|
||
与一处注释行(「禁恒真 check(true)」),行级 grep=1067、真实调用点=1065(2026-10-22 基点实测)。
|
||
- 调用点可跨行:从 `check(` 起括号配平归集完整实参文本(() [] {} 三种括号都计深度);
|
||
字符串字面量内的括号/逗号/井号、行尾注释、转义序列均不参与结构判定(码位掩码单趟预扫)。
|
||
- cond 摘要=cond 原文跨行折叠为单行(连续空白并一空格);标签原文=第二个实参里的字符串
|
||
字面量逐字保真(含引号与转义序列原样,禁改写);标签实参若带格式化/拼接后缀
|
||
(`"..." % [...]`),后缀折叠后以「↩」接排在标签列。
|
||
- md 表格单元格内 `|` 转义为 `\|`、`\` 转义为 `\\`(源码串内无这两序列的歧义形态,读回可还原)。
|
||
|
||
--diff 对账规则:只比对「标签+cond+计数」三要素(合同 §五出口机检);行号随迁移漂移、
|
||
序号重排、白话释义/处置列由维护回填,均不参与比对。对不上的条目分三类报告:
|
||
消失(清单有现码无)、新增(现码有清单无)、成对字面变更(cond 同标签异=标签变更;
|
||
标签同 cond 异=cond 变更——一条「消失」与一条「新增」同因一字之改时按成对呈现)。
|
||
退出码:0=全对齐;1=有差异;2=用法错或文件不可读。
|
||
"""
|
||
import argparse
|
||
import bisect
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
DEFAULT_SRC = ROOT / "game" / "smoke" / "smoke_all.gd"
|
||
DEFAULT_OUT = ROOT / "game" / "tests" / "验证集清单.md"
|
||
|
||
CALL_RE = re.compile(r"check\(")
|
||
DEF_RE = re.compile(r"^\s*(static\s+)?func\s+check\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 = "<module>"
|
||
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):
|
||
"""从 check( 的 start 起括号配平归集调用。返回 (body, end_pos);不配平返回 None。"""
|
||
i = start + len("check(")
|
||
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( 调用点。返回(条目列表, 函数表),条目保持源码顺序。"""
|
||
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 # 字符串字面量内 / 注释里的 check( 字样
|
||
ln = line_of(m.start())
|
||
if DEF_RE.match(lines[ln - 1]):
|
||
continue # func check 定义行
|
||
body, end_pos = collect_call(joined, m.start())
|
||
if body is None:
|
||
raise SystemExit("[extract_checks] FAIL:行 %d 起的 check( 括号不配平到文件尾——先查解析缺口" % ln)
|
||
args = split_args(body)
|
||
if len(args) not in (2, 3):
|
||
raise SystemExit(
|
||
"[extract_checks] FAIL:行 %d 的 check( 拆出 %d 个实参(预期 2/3)——解析缺口,先查再跑"
|
||
% (ln, len(args)))
|
||
cond = args[0]
|
||
label_expr = args[1]
|
||
quiet = args[2] if len(args) == 3 else ""
|
||
lm = LITERAL_RE.search(label_expr)
|
||
if lm is None:
|
||
raise SystemExit(
|
||
"[extract_checks] FAIL:行 %d 的标签实参不含字符串字面量——解析缺口,先查再跑" % ln)
|
||
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 label_cell(e):
|
||
"""标签列:字面量 ↩ 后缀(后缀空则只有字面量)。"""
|
||
sfx = fold(e["label_suffix"])
|
||
return e["label_literal"] + (" ↩ " + sfx if sfx else "")
|
||
|
||
|
||
def entry_key(e):
|
||
"""对账指纹:cond 折叠 + 标签字面量(逐字)+ 后缀折叠 + quiet。"""
|
||
return (fold(e["cond"]), e["label_literal"], fold(e["label_suffix"]), fold(e["quiet"]))
|
||
|
||
|
||
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 "<git 不可用,未取得基点 commit>"
|
||
|
||
|
||
def render_list(entries, funcs, src_rel: str) -> str:
|
||
"""生成验证集清单骨架。白话释义/处置两列留待回填(回填不动冻结列)。"""
|
||
out = []
|
||
out.append("# 验证集清单(迁移参照物·冻结)")
|
||
out.append("")
|
||
out.append("> 生成日期:2026-10-22 · 基点 commit:`%s` · 源:`%s`" % (base_commit(), src_rel))
|
||
out.append("> 提取口径:静态 `check(` 调用点,括号配平归集(可跨行)。基点实测调用点 1065 处;")
|
||
out.append("> 行级 `grep -c \"check(\"` = 1067 是另一口径——多出的 2 行为 `func check(` 定义行与")
|
||
out.append("> 一处注释行(「禁恒真 check(true)」),非调用点。运行时计数大于静态计数=循环展开条目,")
|
||
out.append("> 两口径各自浮动,禁互相换算(tests/README「断言计数双口径」)。")
|
||
out.append("> 生成命令:`python tools/extract_checks.py --extract`(可复现;重新生成须 --force)。")
|
||
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 §9.3 锚)。")
|
||
out.append("")
|
||
seq = 0
|
||
by_func = []
|
||
for e in entries:
|
||
if by_func and by_func[-1][0] == e["func"]:
|
||
by_func[-1][1].append(e)
|
||
else:
|
||
by_func.append((e["func"], [e]))
|
||
for idx, (fn, es) in enumerate(by_func, 1):
|
||
start, end = es[0]["line"], es[-1]["end_line"]
|
||
out.append("## %s %s(行 %d–%d · %d 条 · 去向:待分类回填)" % (pad3(idx), fn, start, end, len(es)))
|
||
out.append("")
|
||
out.append("| 序号 | 行号 | 标签原文 | cond 摘要 | 白话释义 | 处置 | quiet |")
|
||
out.append("|---|---|---|---|---|---|---|")
|
||
for e in es:
|
||
seq += 1
|
||
out.append("| %d | %d | %s | %s | (待填) | | %s |" % (
|
||
seq, e["line"], esc_cell(label_cell(e)), esc_cell(fold(e["cond"])), esc_cell(fold(e["quiet"]))))
|
||
out.append("")
|
||
out.append("---")
|
||
out.append("全源共 %d 章、%d 条(静态调用点口径)。" % (len(by_func), seq))
|
||
return "\n".join(out) + "\n"
|
||
|
||
|
||
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
||
|
||
|
||
def parse_list(path: Path):
|
||
"""读回清单:返回条目列表(键与 entry_key 同构;section 供报告归组)。"""
|
||
entries = []
|
||
cur_section = None
|
||
for i, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1):
|
||
if line.startswith("## "):
|
||
m = re.match(r"## \S+ (\S+)(行", line)
|
||
cur_section = m.group(1) if m else line[3:].strip()
|
||
continue
|
||
if not line.startswith("|"):
|
||
continue
|
||
cells = [c.strip() for c in CELL_SPLIT_RE.split(line)[1:-1]]
|
||
if len(cells) != 7 or not cells[0].isdigit() or cells[0] == "序号":
|
||
continue # 表头/分隔行
|
||
label_raw = unesc_cell(cells[2])
|
||
cond = unesc_cell(cells[3])
|
||
quiet = unesc_cell(cells[6])
|
||
lit_m = LITERAL_RE.match(label_raw)
|
||
literal = lit_m.group(0) if lit_m else label_raw
|
||
suffix = label_raw[lit_m.end():].strip() if lit_m else ""
|
||
suffix = suffix[1:].strip() if suffix.startswith("↩") else suffix
|
||
entries.append({
|
||
"md_line": i,
|
||
"section": cur_section,
|
||
"label_literal": literal,
|
||
"label_suffix": suffix,
|
||
"cond": cond,
|
||
"quiet": quiet,
|
||
})
|
||
return entries
|
||
|
||
|
||
def _short(s, n=80):
|
||
s = s.replace("\n", " ")
|
||
return s if len(s) <= n else s[:n] + "…"
|
||
|
||
|
||
def diff_report(list_path: Path, src_path: Path) -> int:
|
||
cur, _funcs = extract(src_path)
|
||
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, _q = 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 现提取 %s" % (list_path.name, src_path.name))
|
||
print(" 清单条数=%d 现提取条数=%d %s" % (
|
||
len(frozen), len(cur), "(计数对齐)" if len(frozen) == len(cur) else "(计数不齐)"))
|
||
|
||
def show(k, tag):
|
||
cond, lit, sfx, q = 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, "-")
|
||
show(new, "+")
|
||
if cond_changes:
|
||
ok = False
|
||
print(" cond 字面变更 %d 对(标签同、cond 异):" % len(cond_changes))
|
||
for old, new in cond_changes:
|
||
show(old, "-")
|
||
show(new, "+")
|
||
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, "")
|
||
if rest_added:
|
||
ok = False
|
||
print(" 新增 %d 条(现码有、清单无):" % len(rest_added))
|
||
for k in rest_added:
|
||
es = cur_keys[k][0]
|
||
print(" + [func %s @%d]" % (es["func"], es["line"]), end="")
|
||
show(k, "")
|
||
if ok:
|
||
print("[extract_checks] PASS——标签/cond/计数三对齐,零差异")
|
||
return 0
|
||
print("[extract_checks] FAIL——见上差异清单")
|
||
return 1
|
||
|
||
|
||
def list_funcs(entries, funcs):
|
||
by_func = []
|
||
for e in entries:
|
||
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])
|
||
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 main() -> int:
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
ap = argparse.ArgumentParser(description="smoke_all.gd 断言静态提取器(清单生成/对账)")
|
||
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", default=str(DEFAULT_SRC), help="断言源文件")
|
||
ap.add_argument("--out", default=str(DEFAULT_OUT), help="--extract 输出路径")
|
||
ap.add_argument("--force", action="store_true", help="输出文件已存在时允许覆盖")
|
||
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_path = Path(args.src)
|
||
if not src_path.is_file():
|
||
print("[extract_checks] FAIL:源文件不可读:%s" % src_path)
|
||
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
|
||
entries, _funcs = extract(src_path)
|
||
out.write_text(render_list(entries, _funcs, src_path.name), encoding="utf-8", newline="\n")
|
||
print("[extract_checks] 已生成 %s(%d 个调用点)" % (out, len(entries)))
|
||
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_path)
|
||
entries, funcs = extract(src_path)
|
||
list_funcs(entries, funcs)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|