#!/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*\(") 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": "工程化系统,验收面由天赋树与元进度两篇承接,本批迁移实现事实后复核", "生涯.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)) 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): 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())