Files
gplan-2/tools/gdd_lint.py
T
2026-09-14 18:18:49 +08:00

276 lines
11 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"""gdd_lint.py — GDD lean 格式门禁
用法:
python tools/gdd_lint.py # 检查 design/gdd/ 下全部系统级 GDD(默认范围)
python tools/gdd_lint.py <file.md> ... # 检查指定文件
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 行——装不下就拆子节点
"""
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
DEFAULT_DIR = "design/gdd"
DEFAULT_EXCLUDE = {"game-concept.md", "game-pillars.md", "systems-index.md"}
VERSION_SECTION = "版本历史"
FLOW_SECTION_RE = re.compile(r"(流程|生命周期)")
DATE_KEYWORD_RE = re.compile(
r"[((][^()()]*\d{4}-\d{2}-\d{2}[^()()]*"
r"(评审|修订|拍板|裁决|核销|门禁|作废|批)[^()()]*[))]"
)
def parse(text):
"""按行解析:返回代码块行集合、版本历史节行集合、表格块列表、散文段落列表。"""
code_lines, version_lines, tables, paras = set(), set(), [], []
cur_table, cur_para, cur_section = [], [], ""
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:
tables.append(cur_table)
cur_table = []
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)
continue
if cur_table:
tables.append(cur_table)
cur_table = []
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:
tables.append(cur_table)
return code_lines, version_lines, tables, paras
def unescaped_pipes(line):
return len(re.findall(r"(?<!\\)\|", line))
def paren_spans(text):
spans, depth, start = [], 0, 0
for i, ch in enumerate(text):
if ch in "((":
if depth == 0:
start = i
depth += 1
elif ch in "))":
if depth > 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 = 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 段落长度
for p in paras:
if len(p) > MAX_PARA:
issues.append(f"R1 散文段 {len(p)} 字(>{MAX_PARA}): {p[:50]}…")
# R2 表格占比
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 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}」节无表格")
# 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"(?<!\\)\|", table[0])][1:-1]
if not any("规则" in h for h in header_cells):
continue
allowed = {i for i, h in enumerate(header_cells) if ALLOWED_HEADER_RE.search(h)}
for row in table[1:]:
if re.match(r"^\|[\s\-|:]+$", row.strip()):
continue
cells = [c.strip() for c in re.split(r"(?<!\\)\|", row)][1:-1]
for i, cell in enumerate(cells):
if i in allowed or i >= len(header_cells):
continue
m = CITE_RE.search(cell)
if m:
issues.append(
f"R8 核心规则第 {t_idx + 1} 表:说明/要点列混入编号引用「{m.group(0)}」——应放依据列: {cell[:40]}…"
)
break
# 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()