Files
gplan-2/tools/capability_audit.py
T
DKChan ae3c99e34e 工程(能力审计工具): capability_audit.py 入库——七组机检覆盖三层模型的机检半边,本批不接闸
检查组:A 文档纯度(design/gdd 全树禁 .gd/.py/.sh 路径与断言名,围栏块跳过,.tres 表索引放行)/B 系统文档验收标准非空(豁免两件具名附理由)/C 能力行六列结构与格非空/D 代表用例三核(文件存在、在 run_all.gd manifest、func 函数名可达)/E 真实入口方法在产品码有定义(static func 兼容)/F 最后验证批次为真实 commit/G 覆盖域表 25 篇逐篇登记、状态三档、非未覆盖行能力行可回指能力表、部分/未覆盖行缺口指待办清单。自检四道防扫描面空转(文档数/能力行数/套件数/覆盖表行数任一为零 exit 2)。当前实测:能力行 59 全过、覆盖域表 25 行全过,仅余纯度红 124 处(GDD 存量实现细节,归清理批)。接闸与基线登记待清理批之后单独批执行。
2026-10-29 01:54:45 +08:00

349 lines
14 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.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 函数名(`。
E 真实入口可核:能力行「真实入口」的每个 `类.方法()`,方法名须在产品码(game/ 除 tests//probe//sim/)
有 `func 方法(`。
F 最后验证批次:真实 commit(git cat-file 可解析);条件闸行豁免。
G 覆盖域表:systems 每篇一行;状态∈{已覆盖,部分覆盖,未覆盖};非未覆盖行的「能力行」非空且每项都能
对上能力表里的行名;部分/未覆盖行的缺口非空且指待办清单。
自检四道(防扫描面空转假绿):系统文档数、能力行数、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 subprocess
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"
PURITY_RE = re.compile(r"\.(gd|py|sh)\b")
ASSERT_RE = re.compile(r"check_quiet\s*\(|\bcheck\s*\(")
FENCE_RE = re.compile(r"^\s*```")
CAP_COLS = ("能力", "真实入口", "代表用例", "最后验证批次", "缺口")
COV_COLS = ("系统文档", "能力行", "状态", "缺口")
STATUS_VALUES = ("已覆盖", "部分覆盖", "未覆盖")
# 验收标准节豁免:无可写验收面的在册件(逐件附理由;新豁免须经用户裁)
ACCEPT_EXEMPT = {
"锻造工坊.md": "工程化系统,验收面由天赋树与元进度两篇承接,本批迁移实现事实后复核",
"生涯.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*\(\)")
HASH_RE = re.compile(r"^`?([0-9a-f]{7,40})`?$")
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():
names = set()
for p in (REPO / "game").rglob("*.gd"):
rel = p.relative_to(REPO).as_posix()
if rel.startswith(("game/tests/", "game/probe/", "game/sim/")):
continue
names.update(re.findall(r"^(?:static\s+)?func\s+([A-Za-z_]\w*)\s*\(", read(p), re.M))
return names
def commit_ok(short, cache):
if short not in cache:
r = subprocess.run(["git", "-C", str(REPO), "cat-file", "-e", short + "^{commit}"],
capture_output=True)
cache[short] = r.returncode == 0
return cache[short]
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))
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):
total = 0
names = []
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
if "条件闸" in short("代表用例") or "条件闸" in short("缺口"):
continue
check_cases(no, name, short("代表用例"), suite_by_base, funcs)
check_entry(no, name, short("真实入口"), funcs)
check_hash(no, name, short("最后验证批次"))
if total == 0:
selfcheck.append("能力表零数据行")
return names
def check_cases(no, name, cell, suite_by_base, funcs):
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()
hits = suite_by_base.get(fname, [])
if not hits:
red(README.relative_to(REPO), no, "代表用例",
"「%s」%s 不在 run_all.gd 套件 manifest" % (name[:16], fname))
continue
if not any(re.search(r"^(?:static\s+)?func\s+%s\s*\(" % re.escape(fn), read(RUN_ALL.parent / rel), re.M)
for rel in hits):
red(README.relative_to(REPO), no, "代表用例",
"「%s」%s 无 `func %s(`" % (name[:16], fname, fn))
def check_entry(no, name, cell, funcs):
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:
red(README.relative_to(REPO), no, "真实入口",
"「%s」方法 %s() 在产品码无定义" % (name[:16], method))
def check_hash(no, name, cell):
m = HASH_RE.match(cell.strip())
if not m:
red(README.relative_to(REPO), no, "最后验证批次",
"「%s」须为 commit 短哈希:%s" % (name[:16], cell[:24]))
return
if not commit_ok(m.group(1), {}):
red(README.relative_to(REPO), no, "最后验证批次",
"「%s」哈希 %s 不解析为 commit" % (name[:16], m.group(1)))
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()
if not funcs:
selfcheck.append("产品码零 `func`")
names = check_capability_rows(cap_tables, suites, funcs)
check_coverage(cov_tables, names)
if selfcheck:
print("capability_audit 扫描面异常:")
for s in selfcheck:
print(" ! " + s)
print("| ── 能力审计:系统文档 %d 篇 · 能力行 %d · 套件 %d · 覆盖域表 %d 行 · 红项 %d"
% (len(system_docs()), len(names), len(suites),
sum(len(r) for _, r in cov_tables), 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())