diff --git a/tools/capability_audit.py b/tools/capability_audit.py new file mode 100644 index 00000000..15ec757c --- /dev/null +++ b/tools/capability_audit.py @@ -0,0 +1,348 @@ +#!/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())