#!/usr/bin/env python3 r"""gdd_lint.py — GDD lean 格式门禁 用法: python tools/gdd_lint.py # 检查 design/gdd/ 下全部系统级 GDD(默认范围) python tools/gdd_lint.py ... # 检查指定文件 python tools/gdd_lint.py --json # 机器可读输出(供 gate/CI 使用) 规则(与 .claude/docs/templates/game-design-document.md 纪律一一对应): R1 散文段落 ≤ 240 字(代码块/表格/列表/引用不计) R2 表格字符占比 ≥ 60%(代码块不计入分母) R3 正文禁止修订注记:~~删除线~~、"作废"、括号内"日期+评审/修订/拍板/裁决/核销"(版本历史节豁免) R4 必须有「版本历史」节且含表格 R5 流程/状态类章节必须含代码块图(字符画或 Mermaid) R6 表格列数一致(含转义 \| 识别) R7 括号嵌套跨度 ≤ 80 字(长括号 = 审计注记挤进正文的信号) R8 核心规则表:规则列必须是"谁·何时·做什么"陈述句所在的可读表——说明/要点列 禁止出现编号引用(AC-x/F-x/EC-x/E-x/OQ-x/Rule-x/义务),引用只允许在依据类列 R9 一页纸节点:任一标题节点的直接内容(到下一标题为止)≤ 80 行——装不下就拆子节点 R10 版本历史版本号唯一:版本历史/修订记录节表格首列提取 v\d+(\.\d+)* 或裸数字编号查重 (表头/分隔行/空值/日期式首列跳过);无版本历史表的文档不适用——存在性归 R4 R11 计数词漂移:正文禁用旧计数口径——"10 类行/行型封闭为 10 类/10 类封闭协议/行型 10 类" →9 类、"七型/五型 × 正负"→八型、"15 类"→9 类、"14 格"→十六格;版本历史/修订记录节、 留档/作废/退役/历史/旧/改型/随批/顺延等轨迹措辞行、`;` 注释行豁免(现行口径见 registry) """ import argparse import json import re import sys from pathlib import Path MAX_PARA = 240 # 2026-09-14 放宽 60→30:lean 改革后的新模板(参考 analysis/design 两件套)允许 # 短段落与编号人话规则——防铺开的职责改由 R1(段 ≤240 字)与 R9(节点 ≤80 行)承担, # R2 降为兜底下限(防纯散文文档)。 MIN_TABLE_RATIO = 30 MAX_PAREN_SPAN = 80 # 规则分域(2026-10-03 用户裁「测试要快/要准」优化批):R1/R2 的体裁前提是「规范文」 # (短段+表格化);登记处/清单/概念文的自然形态就是长条目与叙事散文,硬套=自 2026-09-20 # 上线起 8/8 常红零过滤价值(test-perf-report-2026-10-02 §6.2 实证)。R3/R4/R7 不豁免—— # 「provenance 出正文、版本历史在案」对一切体裁有效。 R1_EXEMPT = { "06-待办清单.md", # 全项目待办登记处:现状/状态/方案长条目为体裁 "07-体验验证清单.md", # 体验验证读数清单:同上 } R2_EXEMPT = { "01-核心循环.md", # 循环概念文:叙事散文为体裁 "04-美术音频方向.md", # 美术方向文:同上 "05-不做清单.md", # 清单文:条目散文为体裁 "06-待办清单.md", "07-体验验证清单.md", } DEFAULT_DIR = "design/gdd" DEFAULT_EXCLUDE = {"game-concept.md", "game-pillars.md", "systems-index.md"} VERSION_SECTION = "版本历史" # 版本历史/修订记录节(R10 查重域与 R11 豁免域共用;battle.md 等用「修订记录」同名节) HISTORY_SECTION_RE = re.compile(r"(版本历史|修订记录)") FLOW_SECTION_RE = re.compile(r"(流程|生命周期)") # R11 计数词漂移对照锚表。事实源 = design/registry/entities.yaml:359 行协议 9 类 / :314 八型 / # :327 权重 74;口径变动时同步此处。 CURRENT_COUNTS = [ (re.compile(r"(?:10 类行|行型封闭为 10 类|10 类封闭协议|行型 10 类)"), "9 类"), (re.compile(r"七型"), "八型"), (re.compile(r"(?:五型 × 正负|五型×正负)"), "八型"), (re.compile(r"15 类"), "9 类"), (re.compile(r"14 格"), "十六格"), ] # R11 轨迹措辞豁免:留档/作废/退役/历史/旧 + 改型/随批/顺延(registry 历史轨迹句「同批改型」) R11_TRACE_RE = re.compile(r"(留档|作废|退役|历史|旧|改型|随批|顺延)") # R10 版本号提取:v 前缀优先,裸数字编号兜底;日期式首列(修订记录表)整体跳过 VER_TOKEN_RE = re.compile(r"[vV]\d+(?:\.\d+)*") BARE_VER_RE = re.compile(r"^\d+(?:\.\d+)*$") DATE_CELL_RE = re.compile(r"\d{4}-\d{2}-\d{2}") SEPARATOR_ROW_RE = re.compile(r"^\|[\s\-|:]+$") DATE_KEYWORD_RE = re.compile( r"[((][^()()]*\d{4}-\d{2}-\d{2}[^()()]*" r"(评审|修订|拍板|裁决|核销|门禁|作废|批)[^()()]*[))]" ) def parse(text): """按行解析:返回代码块行集合、版本历史节行集合、表格块列表、散文段落列表、 版本历史/修订记录节表格列表(R10 查重域)。""" code_lines, version_lines, tables, paras = set(), set(), [], [] version_tables = [] cur_table, cur_para, cur_section = [], [], "" cur_table_hist = False in_code = False for raw in text.splitlines(): stripped = raw.strip() if stripped.startswith("```"): in_code = not in_code code_lines.add(raw) if cur_para: paras.append(" ".join(cur_para)) cur_para = [] if cur_table: (version_tables if cur_table_hist else tables).append(cur_table) cur_table, cur_table_hist = [], False continue if raw.startswith("## "): cur_section = stripped.lstrip("#").strip() if stripped.startswith("|"): if cur_para: paras.append(" ".join(cur_para)) cur_para = [] cur_table.append(raw) if cur_section == VERSION_SECTION: version_lines.add(raw) if HISTORY_SECTION_RE.search(cur_section): cur_table_hist = True continue if cur_table: (version_tables if cur_table_hist else tables).append(cur_table) cur_table, cur_table_hist = [], False is_para = ( stripped and not in_code and not stripped.startswith(("#", ">", "- ", "* ")) and not re.match(r"^\d+[.、))]\s", stripped) ) if in_code: code_lines.add(raw) elif cur_section == VERSION_SECTION: version_lines.add(raw) elif is_para: cur_para.append(stripped) elif cur_para: paras.append(" ".join(cur_para)) cur_para = [] if cur_para: paras.append(" ".join(cur_para)) if cur_table: (version_tables if cur_table_hist else tables).append(cur_table) return code_lines, version_lines, tables, paras, version_tables def unescaped_pipes(line): return len(re.findall(r"(? 0: depth -= 1 if depth == 0: spans.append(text[start : i + 1]) return spans MAX_NODE_LINES = 80 def lint_file(path): text = Path(path).read_text(encoding="utf-8") code_lines, version_lines, tables, paras, version_tables = parse(text) issues, infos = [], [] # R9 一页纸节点:每个标题的直接内容 ≤ MAX_NODE_LINES 行 in_code, node_name, node_count = False, None, 0 for raw in text.splitlines(): if raw.strip().startswith("```"): in_code = not in_code if not in_code and re.match(r"^#{1,6}\s", raw): if node_name is not None and node_count > MAX_NODE_LINES: issues.append( f"R9 节点「{node_name}」直接内容 {node_count} 行(>{MAX_NODE_LINES})——超一页,拆为子节点" ) node_name = raw.lstrip("#").strip()[:40] node_count = 0 continue if raw.strip() and node_name is not None: node_count += 1 if node_name is not None and node_count > MAX_NODE_LINES: issues.append(f"R9 节点「{node_name}」直接内容 {node_count} 行(>{MAX_NODE_LINES})——超一页,拆为子节点") infos.append(f"最大节点直接内容 {max(node_count, 0)} 行") # R1 段落长度(登记处/清单文分域豁免,见 R1_EXEMPT 注) fname = Path(path).name if fname not in R1_EXEMPT: for p in paras: if len(p) > MAX_PARA: issues.append(f"R1 散文段 {len(p)} 字(>{MAX_PARA}): {p[:50]}…") # R2 表格占比(概念/方向/清单文分域豁免,见 R2_EXEMPT 注) code_chars = sum(len(l) for l in code_lines) table_chars = sum(len(l) for l in text.splitlines() if l.strip().startswith("|")) total = max(len(text) - code_chars, 1) ratio = int(100 * table_chars / total) if fname in R2_EXEMPT: infos.append(f"表格占比 {ratio}%(R2 分域豁免)") elif ratio < MIN_TABLE_RATIO: issues.append(f"R2 表格占比 {ratio}%(<{MIN_TABLE_RATIO}%)") else: infos.append(f"表格占比 {ratio}%") # R3 修订注记(版本历史节豁免;代码块豁免) body_no_code = "\n".join( l for l in text.splitlines() if l not in code_lines and l not in version_lines ) for m in re.finditer(r"~~.+?~~", body_no_code): issues.append(f"R3 删除线残留: …{m.group(0)[:40]}…") if re.search(r"作废", body_no_code): line_no = next( i + 1 for i, l in enumerate(body_no_code.splitlines()) if "作废" in l ) issues.append(f"R3 \"作废\"注记残留(第 {line_no} 行附近)") for m in DATE_KEYWORD_RE.finditer(body_no_code): issues.append(f"R3 正文修订注记: {m.group(0)[:50]}") # R4 版本历史 if VERSION_SECTION not in text: issues.append(f"R4 缺「{VERSION_SECTION}」节") elif not any(l.strip().startswith("|") for l in version_lines): issues.append(f"R4 「{VERSION_SECTION}」节无表格") # R10 版本历史版本号唯一性(实证撞号:v0.19 曾被并行两批同时占用) ver_seen = {} # key: 归一版本号 → [首次原样 token, 出现次数] for table in version_tables: for row in table[1:]: # 首行 = 表头 if SEPARATOR_ROW_RE.match(row.strip()): continue cells = [c.strip() for c in re.split(r"(? 1: issues.append(f"R10 版本历史版本号重复: {first}({count} 处)") # R5 流程章节须含代码块图 cur_section, prev_section = "", "" pending_flow, has_code = False, False for raw in text.splitlines(): stripped = raw.strip() if raw.startswith("## "): if pending_flow and not has_code: issues.append(f"R5 「{prev_section or cur_section}」节无流程图(须含代码块字符画/Mermaid)") prev_section, cur_section = cur_section, stripped.lstrip("#").strip() pending_flow = bool(FLOW_SECTION_RE.search(cur_section)) and cur_section != VERSION_SECTION has_code = False elif stripped.startswith("```") and cur_section: has_code = True if pending_flow and not has_code: issues.append(f"R5 「{cur_section}」节无流程图(须含代码块字符画/Mermaid)") # R6 表格列数一致 for t_idx, table in enumerate(tables): if len(table) < 2: continue base = unescaped_pipes(table[0]) for row in table[1:]: if unescaped_pipes(row) != base: issues.append(f"R6 第 {t_idx + 1} 个表格列数不一致: {row.strip()[:50]}") # R8 核心规则表:编号引用不得混入说明/要点列(只允许在依据类列) CITE_RE = re.compile(r"(AC-\d|EC\d|OQ\d|\bF\d|\bE\d+\b|Rule \d|义务)") ALLOWED_HEADER_RE = re.compile(r"(#|编号|依据|来源|出处|对侧|归属|状态|覆盖|版本|日期|owner|Owner|级别)") for t_idx, table in enumerate(tables): if len(table) < 3: continue header_cells = [c.strip() for c in re.split(r"(?= len(header_cells): continue m = CITE_RE.search(cell) if m: issues.append( f"R8 核心规则第 {t_idx + 1} 表:说明/要点列混入编号引用「{m.group(0)}」——应放依据列: {cell[:40]}…" ) break # R11 计数词漂移对照(锚表 CURRENT_COUNTS;版本历史/修订记录节与轨迹措辞行豁免) in_code11, hist11 = False, False for raw in text.splitlines(): stripped = raw.strip() if stripped.startswith("```"): in_code11 = not in_code11 continue if raw.startswith("## "): hist11 = bool(HISTORY_SECTION_RE.search(stripped.lstrip("#").strip())) continue if in_code11 or hist11 or stripped.startswith(";") or R11_TRACE_RE.search(raw): continue # yaml/yml 的 # 行内注释是登记轨迹(如 entities.yaml「2026-09-18 扩七型」),不入正文扫描 line = raw.split("#", 1)[0] if str(path).endswith((".yaml", ".yml")) else raw for pat, fix in CURRENT_COUNTS: for m in pat.finditer(line): issues.append( f'R11 计数词漂移: "{m.group(0)}"→应为"{fix}"(现行口径见 registry entities.yaml)' ) # R7 括号跨度 longest = max((paren_spans(p) for p in paras), key=lambda s: max((len(x) for x in s), default=0), default=[]) longest_span = max((len(s) for s in longest), default=0) if longest_span > MAX_PAREN_SPAN: issues.append(f"R7 最长括号跨度 {longest_span} 字(>{MAX_PAREN_SPAN})——疑似审计注记挤进正文") infos.append(f"最长括号跨度 {longest_span} 字") return {"file": str(path), "issues": issues, "infos": infos, "table_ratio": ratio} def main(): ap = argparse.ArgumentParser(description="GDD lean 格式门禁") ap.add_argument("files", nargs="*", help="待检查文件(默认 design/gdd/ 下系统级 GDD)") ap.add_argument("--json", action="store_true", help="JSON 输出") args = ap.parse_args() if args.files: targets = [Path(f) for f in args.files] else: root = Path(DEFAULT_DIR) targets = sorted( p for p in root.glob("*.md") if p.name not in DEFAULT_EXCLUDE and not re.search(r"review|report", p.name, re.I) ) if not targets: print(f"未找到待检文件({DEFAULT_DIR}/)", file=sys.stderr) sys.exit(2) results = [lint_file(p) for p in targets] failed = [r for r in results if r["issues"]] if args.json: print(json.dumps(results, ensure_ascii=False, indent=2)) else: for r in results: mark = "❌ FAIL" if r["issues"] else "✅ PASS" print(f"\n{mark} {r['file']} (表格占比 {r['table_ratio']}%)") for i in r["infos"]: print(f" · {i}") for issue in r["issues"]: print(f" ! {issue}") print(f"\n共 {len(results)} 个文件,{len(failed)} 个不通过") sys.exit(1 if failed else 0) if __name__ == "__main__": main()