Files
gplan-2/tools/extract_checks.py
T
DKChan 771f871527 feat(测试迁移): 批 B 波 0 基建——断言静态提取器+验证集清单冻结+迁移账本
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 空);三个去向既有测试文件零改动;本波零游戏测试。
2026-10-22 23:18:20 +08:00

495 lines
19 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 -*-
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())