Files
gplan-2/tools/gdd_lint.py
T

367 lines
16 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 行——装不下就拆子节点
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"(?<!\\)\|", 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, 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"(?<!\\)\|", row)][1:-1]
cell = cells[0] if cells else ""
if not cell or DATE_CELL_RE.search(cell):
continue # 空值 / 日期式首列(修订记录表以日期为键)不参与查重
m = VER_TOKEN_RE.search(cell)
if m:
key = m.group(0).lower().lstrip("v")
elif BARE_VER_RE.match(cell):
key = cell
else:
continue
ver_seen.setdefault(key, [cell, 0])[1] += 1
for first, count in ver_seen.values():
if count > 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"(?<!\\)\|", 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
# 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()