Files
gplan-2/tools/capability_audit.py
T

340 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.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 函数名(`)或卡在(该文件名或函数名能在 reports/需求卡片*.md 中找到,
供写卡时当场入表的计划用例锚定);两头都无=红(清单挂了无凭证义务)。
E 真实入口可核(双通道):能力行「真实入口」的每个 `类.方法()`——产品码(game/ 除 tests//probe//sim/)
有 `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():
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 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 = []
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("代表用例"):
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
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 在文件里)
# 或卡在(文件名/函数名见于 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)
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() 无凭证——产品码无定义且需求卡片未载" % (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()
if not funcs:
selfcheck.append("产品码零 `func`")
names = check_capability_rows(cap_tables, suites, funcs, cards_corpus())
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())