Files
gplan-2/tools/capability_audit.py
T
DKChan 909a9709b4 收尾(迭代53批后收尾)+CI追绿修复: 反哺点落文(用户四点裁决在案)——反哺点3 GDD退役名清退(术语表v1.27/地图战斗资源v1.6掷格件两路投影换机制名订阅加权、00-游戏概念删旧六行专名、奇物与03-内容规模退役记录句保留且后者删过时尾句)+反哺点4规范句落战斗结算v1.8(明细文案=数据面字段读表拼行,用户裁入表)+反哺点7 golden台账补lmap冻结日+反哺点8能力行名实回表_and_columns+反哺点9 test-conventions探针直读受控例外注记; 未决迁账面(反哺点4新立结算明细文案入表条目待文档设计/反哺点5闸机超时守卫缺陷与反哺点6 mac两Shot基线登记待执行); 体验验证清单52节两目检项+实现报告并入审计结论(通过·D1/D2/D3去向在案); 吸收即死退役五件(反哺点清单/实现报告/验收审计/两需求卡片,退役对账承接面核过); roadmap迭代53行已完成+当前位换代(迭代1–53); CI追红修复: forge_table_test效果类型闭集32→31(卡②摘spike断言随动)+level_map_info_fog_test布局缓存换代断言第三判据等强换键(位置差前提随画布同帧收敛失义——缓存失效重算本身正常,换键=重算字典键数恰为新图节点数,同一性判据不动)+capability_audit真实入口锚面补probe_funcs(探针共用面func定义,与代表用例D规则同口径不属放宽)+level_map_shot_probe.gd.uid边车补入库+待办清单QA条目补仓库内证据句(修流水线闸run86起红)。验证: 两修复套件窄测绿(forge 42 checks/fog 21 checks全绿)+流水线闸PASS+能力审计红项0+--docs全绿(合回前复跑凭据见本笔后续回执)
2026-11-06 01:12:55 +08:00

372 lines
16 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
r"""capability_audit.py — 能力清单、覆盖域表与 GDD 纯度机检(文档域闸件)
职责(test-conventions §8.9 三层模型的机检半边;规则正文=test-conventions §8.3+§8.9+doc-conventions §5):
A 文档纯度:design/gdd 全树现行文档禁写实现细节——代码路径(.gd/.py/.sh)、测试文件名、断言名、
反引号包裹的调用形态(`名字(...)`);唯一例外=配置文件索引(.tres 表路径与表名)。围栏代码块内不扫。
B 验收标准:design/gdd/systems/** 每篇须有非空的「## 验收标准」节(豁免件具名在册,附理由)。
C 能力行结构:game/tests/README.md 能力表四列齐、逐行四格非空。
D 代表用例可核(双通道):能力行「代表用例」的每条 `文件:函数`——代码在(文件在 run_all.gd 套件
manifest 内且该文件有 `func 函数名(`,或该文件是 game/probe/ 下的探针件且件内有 `func 函数名(`——
探针不进 run_all manifest,单独认探针面)或卡在(该文件名或函数名能在 reports/需求卡片*.md 中找到,
供写卡时当场入表的计划用例锚定);两头都无=红(清单挂了无凭证义务)。
E 真实入口可核(双通道):能力行「真实入口」的每个 `类.方法()`——产品码+sim 测量件(game/ 除
tests//probe/)有 `func 方法(`,或该方法名能在 reports/需求卡片*.md 中找到(计划入口同 D 口径);两头都无=红。
F 覆盖域表:systems 每篇一行;状态∈{已覆盖,部分覆盖,未覆盖};非未覆盖行的「能力行」非空且每项都能
对上能力表里的行名;部分/未覆盖行的缺口非空且指待办清单。
条件闸行豁免(代表用例含「条件闸」字样)不参与 D/E。
自检四道(防扫描面空转假绿):系统文档数、能力行数、manifest 套件数、覆盖表行数任一为零 ⇒ exit 2。
用法:
python tools/capability_audit.py # 报告模式(红项只打印,不改退出码)
python tools/capability_audit.py --strict # 有红项时 exit 1(供门禁)
exit:0=绿/1=有红项(--strict)/2=扫描面异常
"""
import argparse
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
GDD_DIR = REPO / "design" / "gdd"
SYS_DIR = GDD_DIR / "systems"
README = REPO / "game" / "tests" / "README.md"
RUN_ALL = REPO / "game" / "tests" / "run_all.gd"
CARDS_DIR = REPO / "reports"
PURITY_RE = re.compile(r"\.(gd|py|sh)\b")
ASSERT_RE = re.compile(r"check_quiet\s*\(|\bcheck\s*\(")
CALL_FORM_RE = re.compile(r"`[A-Za-z_][A-Za-z0-9_.]*\s*\([^`]*\)`")
FENCE_RE = re.compile(r"^\s*```")
CAP_COLS = ("能力", "系统", "真实入口", "代表用例")
COV_COLS = ("系统文档", "能力行", "状态", "缺口")
STATUS_VALUES = ("已覆盖", "部分覆盖", "未覆盖")
# 验收标准节豁免:无可写验收面的在册件(逐件附理由;新豁免须经用户裁)
ACCEPT_EXEMPT = {
"生涯.md": "P6 后置未实装,无现成可观测面;实装批补验收标准节",
}
CASE_RE = re.compile(r"([A-Za-z0-9_\-]+\.gd):([A-Za-z_][A-Za-z0-9_]*)")
ENTRY_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\s*\(\)")
reds = []
selfcheck = []
def red(path, line, check, msg):
reds.append((str(path), line, check, msg))
def read(path):
return path.read_text(encoding="utf-8")
def cells(raw):
return [c.strip() for c in raw.strip().strip("|").split("|")]
def is_sep(cs):
return all(set(c) <= set(":- ") for c in cs if c)
def tables(lines, required):
"""产出国标表格:(header_cells, [(源行号, cells)]),表头须含 required 全部列关键词。"""
i = 0
while i < len(lines):
if lines[i].strip().startswith("|"):
block = []
j = i
while j < len(lines) and lines[j].strip().startswith("|"):
block.append((j + 1, lines[j]))
j += 1
if len(block) >= 2:
header = cells(block[0][1])
if all(any(req in c for c in header) for req in required):
rows = []
for no, raw in block[1:]:
cs = cells(raw)
if is_sep(cs):
continue
rows.append((no, cs))
yield header, rows
i = j
else:
i += 1
def col(header, key):
return next((k for k, c in enumerate(header) if key in c), None)
def strip_fences(text):
out, inside = [], False
for ln in text.splitlines():
if FENCE_RE.match(ln):
inside = not inside
out.append("")
continue
out.append("" if inside else ln)
return out
def system_docs():
return sorted(SYS_DIR.rglob("*.md"))
def manifest():
return re.findall(r'"path":\s*"res://tests/([^"]+)"', read(RUN_ALL))
def product_funcs():
# 「代码在」锚面=产品码(game/ 除 tests//probe/)+sim 测量件:调参测量与模拟驱动件
# (battle_driver/auto_player 等)是合法的真实入口宿主,卡片退役后其入口只能锚代码面。
names = set()
for p in (REPO / "game").rglob("*.gd"):
rel = p.relative_to(REPO).as_posix()
if rel.startswith(("game/tests/", "game/probe/")):
continue
names.update(re.findall(r"^(?:static\s+)?func\s+([A-Za-z_]\w*)\s*\(", read(p), re.M))
return names
def probe_funcs():
# 真实入口锚面补探针共用面(capability E 规则用):真输入合成沿(probe_ui_util 等探针
# 共用件)写进能力行真实入口格后,卡片退役其凭证只能锚代码面——探针件定义与 D 规则
# 同口径认锚(仍要求 func 真实存在,不属放宽)。
names = set()
for p in (REPO / "game" / "probe").rglob("*.gd"):
names.update(re.findall(r"^(?:static\s+)?func\s+([A-Za-z_]\w*)\s*\(", read(p), re.M))
return names
def cards_corpus():
"""reports/需求卡片*.md 全文拼接(双通道的「卡在」锚面);无卡返回空串。"""
if not CARDS_DIR.is_dir():
return ""
return "\n".join(read(p) for p in sorted(CARDS_DIR.glob("需求卡片-*.md")))
def check_purity():
docs = sorted(GDD_DIR.rglob("*.md"))
if not docs:
selfcheck.append("design/gdd 下无文档")
return
for doc in docs:
for no, ln in enumerate(strip_fences(read(doc)), 1):
for m in PURITY_RE.finditer(ln):
red(doc.relative_to(REPO), no, "纯度", "代码路径 `%s`(禁写实现细节)" % m.group(0))
m = ASSERT_RE.search(ln)
if m:
red(doc.relative_to(REPO), no, "纯度", "断言名 `%s`(禁写实现细节)" % m.group(1))
m = CALL_FORM_RE.search(ln)
if m:
red(doc.relative_to(REPO), no, "纯度", "反引号调用形态 %s(禁写实现细节)" % m.group(0))
def check_accept_sections():
for doc in system_docs():
text = read(doc)
m = re.search(r"^## 验收标准[^\n]*\n(.*?)(?=^## |\Z)", text, re.M | re.S)
body = (m.group(1).strip() if m else "")
if not body and doc.name not in ACCEPT_EXEMPT:
red(doc.relative_to(REPO), 1, "验收标准",
"缺非空「## 验收标准」节" if not m else "「## 验收标准」节为空")
elif doc.name in ACCEPT_EXEMPT and body:
red(doc.relative_to(REPO), 1, "验收标准", "豁免件已有验收标准节,请移除豁免")
def capability_rows():
if not README.exists():
selfcheck.append("game/tests/README.md 缺失")
return [], []
lines = read(README).splitlines()
cap_tables = list(tables(lines, ("真实入口", "代表用例")))
cov_tables = list(tables(lines, ("系统文档", "能力行", "状态")))
if not cap_tables:
selfcheck.append("README 能力表未找到")
if not cov_tables:
selfcheck.append("README 覆盖域表未找到")
return cap_tables, cov_tables
def check_capability_rows(cap_tables, suites, funcs, cards):
total = 0
names = []
referenced = set()
suite_by_base = {}
for rel in suites:
suite_by_base.setdefault(Path(rel).name, []).append(rel)
for header, rows in cap_tables:
idx = {}
for key in CAP_COLS:
k = col(header, key)
if k is None:
red(README.relative_to(REPO), 1, "能力行结构", "缺「%s」列" % key)
break
idx[key] = k
else:
for no, cs in rows:
total += 1
short = lambda k: (cs[idx[k]] if idx[k] < len(cs) else "")
name = short("能力").strip("`")
names.append(name)
empty = [k for k in CAP_COLS if not short(k)]
if len(cs) < max(idx.values()) + 1 or empty:
red(README.relative_to(REPO), no, "能力行结构",
"「%s」行格数不足或空列(%s)" % (name[:16], "/".join(empty) or "列数"))
continue
referenced.update(m.group(1) for m in CASE_RE.finditer(short("代表用例")))
if "条件闸" in short("代表用例"):
continue
check_cases(no, name, short("代表用例"), suite_by_base, funcs, cards)
check_entry(no, name, short("真实入口"), funcs, cards)
if total == 0:
selfcheck.append("能力表零数据行")
return names, referenced
def check_suite_coverage(suites, referenced):
"""链式锚反向覆盖(test-conventions §8.3 退役对账):run_all.gd manifest 的每个套件
必须被 ≥1 个能力行「代表用例」引用——断言(尾缀挂卡/挂套件)→套件→能力行→系统文档
的链条中,套件未接能力行=卡片退役后验收意图断链(营地图批先例)。"""
missing = [rel for rel in suites if Path(rel).name not in referenced]
for rel in sorted(missing):
red(README.relative_to(REPO), 1, "链式锚",
"套件 %s 未被任何能力行「代表用例」引用——断言→套件→能力行→系统文档链条断环" % rel)
return missing
def check_cases(no, name, cell, suite_by_base, funcs, cards):
chunks = [c.strip().strip("`") for c in re.split(r"[;;]", cell) if c.strip()]
if not chunks:
red(README.relative_to(REPO), no, "代表用例", "「%s」无代表用例" % name[:16])
return
for chunk in chunks:
m = CASE_RE.search(chunk)
if not m:
red(README.relative_to(REPO), no, "代表用例",
"「%s」格格式应为 `文件.gd:函数`:%s" % (name[:16], chunk[:40]))
continue
fname, fn = m.groups()
# 双通道(test-conventions §8.9):代码在(套件 manifest 内且 func 在文件里;
# 探针不进 run_all manifest——game/probe/ 下探针件在库且 func 在件内同样算代码在)
# 或卡在(文件名/函数名见于 reports/需求卡片*.md——写卡时当场入表的计划用例)。
hits = suite_by_base.get(fname, [])
code_ok = bool(hits) and any(
re.search(r"^(?:static\s+)?func\s+%s\s*\(" % re.escape(fn), read(RUN_ALL.parent / rel), re.M)
for rel in hits)
if not code_ok:
probe = REPO / "game" / "probe" / fname
code_ok = probe.is_file() and bool(
re.search(r"^(?:static\s+)?func\s+%s\s*\(" % re.escape(fn), read(probe), re.M))
card_ok = bool(cards) and (fname in cards or fn in cards)
if not code_ok and not card_ok:
red(README.relative_to(REPO), no, "代表用例",
"「%s」%s 无凭证——代码面(manifest+func)与需求卡片均未锚定" % (name[:16], chunk[:40]))
def check_entry(no, name, cell, funcs, cards):
tokens = ENTRY_RE.findall(cell)
if not tokens:
red(README.relative_to(REPO), no, "真实入口", "「%s」无可核的 `类.方法()`" % name[:16])
return
for _, method in tokens:
if method not in funcs and not (cards and method in cards):
red(README.relative_to(REPO), no, "真实入口",
"「%s」方法 %s() 无凭证——产品码、sim 测量件与探针件均无定义且需求卡片未载" % (name[:16], method))
def check_coverage(cov_tables, names):
want = {d.name for d in system_docs()}
if not want:
selfcheck.append("systems 下无系统文档")
return
total, seen = 0, {}
for header, rows in cov_tables:
idx = {}
ok = True
for key in COV_COLS:
k = col(header, key)
if k is None:
red(README.relative_to(REPO), 1, "覆盖域表", "缺「%s」列" % key)
ok = False
break
idx[key] = k
if not ok:
continue
for no, cs in rows:
total += 1
short = lambda k: (cs[idx[k]] if idx[k] < len(cs) else "")
doc = Path(short("系统文档").strip("`").strip()).name
if not doc:
red(README.relative_to(REPO), no, "覆盖域表", "系统文档格为空")
continue
if doc in seen:
red(README.relative_to(REPO), no, "覆盖域表", "%s 重复登记(见行 %d)" % (doc, seen[doc]))
seen[doc] = no
status = short("状态").strip("`").strip()
if status not in STATUS_VALUES:
red(README.relative_to(REPO), no, "覆盖域表",
"%s 状态须为 已覆盖/部分覆盖/未覆盖:%s" % (doc, status[:16]))
continue
caps = [c.strip() for c in re.split(r"[、;;]", short("能力行")) if c.strip()]
gap = short("缺口").strip()
if status != "未覆盖" and not caps:
red(README.relative_to(REPO), no, "覆盖域表", "%s 非未覆盖但能力行为空" % doc)
for cap in caps:
cap = cap.strip("`")
if cap in ("—", "-"):
continue
if not any(cap in n or n in cap for n in names):
red(README.relative_to(REPO), no, "覆盖域表",
"%s 的能力行「%s」在能力表中不存在" % (doc, cap[:20]))
if status != "已覆盖" and (not gap or "待办清单" not in gap):
red(README.relative_to(REPO), no, "覆盖域表",
"%s 为%s,缺口须非空并指待办清单" % (doc, status))
if total == 0:
selfcheck.append("覆盖域表零数据行")
for doc in sorted(want - set(seen)):
red(README.relative_to(REPO), 1, "覆盖域表", "%s 未在覆盖域表登记" % doc)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--strict", action="store_true")
args = ap.parse_args()
check_purity()
check_accept_sections()
cap_tables, cov_tables = capability_rows()
suites = manifest()
if not suites:
selfcheck.append("run_all.gd manifest 零套件")
funcs = product_funcs() | probe_funcs()
if not funcs:
selfcheck.append("产品码零 `func`")
names, referenced = check_capability_rows(cap_tables, suites, funcs, cards_corpus())
missing_suites = check_suite_coverage(suites, referenced)
check_coverage(cov_tables, names)
if selfcheck:
print("capability_audit 扫描面异常:")
for s in selfcheck:
print(" ! " + s)
print("| ── 能力审计:系统文档 %d 篇 · 能力行 %d · 套件 %d · 覆盖域表 %d 行 · 链断套件 %d · 红项 %d"
% (len(system_docs()), len(names), len(suites),
sum(len(r) for _, r in cov_tables), len(missing_suites), len(reds)))
for path, line, check, msg in reds:
print(" ✗ [%s] %s:%d %s" % (check, path, line, msg))
if selfcheck:
return 2
if reds and args.strict:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())