378 lines
16 KiB
Python
378 lines
16 KiB
Python
#!/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():
|
||
# GBK 控制台兜底(红项明细含 ✗ 等非 GBK 字符,编码崩=红项被吞不可见)
|
||
for stream in (sys.stdout, sys.stderr):
|
||
try:
|
||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||
except AttributeError:
|
||
pass
|
||
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())
|