Files
gplan-2/tools/gdd_lint.py
T

343 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 + design/art/ + roadmap.md(默认范围)
python tools/gdd_lint.py --changelog-only # 只跑 R4 变更记录节禁令,扫描面扩到全 design/ 树
python tools/gdd_lint.py <file.md> ... # 检查指定文件
python tools/gdd_lint.py --json # 机器可读输出(供 gate/CI 使用)
规则(口径权威 = design/engineering/doc-conventions.md 第 5–6 节文档骨架与结构纪律):
R1 散文段落 ≤ 240 字(代码块/表格/列表/引用不计)
R3 正文禁止修订注记:~~删除线~~、"作废"、括号内"日期+评审/修订/拍板/裁决/核销"
R4 禁止变更记录节:出现「版本历史/修订记录/变更记录/修改记录/更新记录/更新日志」标题即红
(沿革只进 git commit log;扫描面 = 全 design/ + roadmap.md,见 --changelog-only)
R5 流程/状态类章节必须含代码块图(字符画或 Mermaid);词条节按 RULE_EXEMPT 分域豁免
R6 表格列数一致(含转义 \| 识别)
R7 括号嵌套跨度 ≤ 80 字(长括号 = 审计注记挤进正文的信号)
R8 核心规则表:规则列必须是"谁·何时·做什么"陈述句所在的可读表——说明/要点列
禁止出现编号引用(AC-x/F-x/EC-x/E-x/OQ-x/Rule-x/义务),引用只允许在依据类列
R9 一页纸节点:任一标题节点的直接内容(到下一标题为止)≤ 80 行——装不下就拆子节点
R11 计数词漂移:正文禁用旧计数口径——"10 类行/行型封闭为 10 类/10 类封闭协议/行型 10 类"
→9 类、"七型/五型 × 正负"→八型、"15 类"→9 类、"14 格"→十六格;
留档/作废/退役/历史/旧/改型/随批/顺延等轨迹措辞行、`;` 注释行豁免(现行口径见 owner GDD 与 术语表)
"""
import argparse
import json
import re
import sys
from pathlib import Path
MAX_PARA = 240
# R2 表格占比判据已停用(2026-10-20 规则审阅批):lean 体系退役后其解读对象已不存在,
# 分域豁免越加越多、只产存量噪音(af94ce97 指纹刷新为证),判据与 R2_EXEMPT 一并拆除。
MAX_PAREN_SPAN = 80
# 规则分域(2026-10-03 用户裁「测试要快/要准」优化批):R1 的体裁前提是「规范文」
# (短段+表格化)。「provenance 出正文」对一切体裁有效;R4 变更记录节禁令自 2026-10-19
# 起对一切体裁有效。
# 规则粒度分域豁免(2026-10-15 用户令「清单类文件与术语表不做 GDD 规范性检查」)。
# 原则:**按规则豁免,不按文件拉黑**——体裁规则对词条表不适用,但机械可判的完整性规则
# (R4/R6/R8/R9/R11)一律不豁免,真缺陷不得藏起来。
# R5 流程/状态类节须含代码块图——节名含「流程/生命周期」即触发,术语表的「开发流程」
# 是词条节、不是流程描述,属节名误匹配。
RULE_EXEMPT = {
"术语表.md": {"R5"},
}
# 美术域收编(2026-10-28 美术族成文整备批):design/art/ 为高密度规格/清单/提示词操作单体裁,
# 体裁类规则(R1 段长/R5 流程图/R7 括号跨度/R8 规则表/R9 节点行数)不适用、豁免防误报;
# R11 的锚表是 GDD 行协议/格面资源专属计数,对美术域同样不适用。
# 跨体裁有效面照常执法:R3 修订注记、R4 变更记录节、R6 表格列数。
ART_DOMAIN_EXEMPT = {"R1", "R5", "R7", "R8", "R9", "R11"}
ART_DIR = "design/art"
DEFAULT_DIR = "design/gdd"
DEFAULT_EXCLUDE = {"game-concept.md", "game-pillars.md", "systems-index.md"}
# R4 变更记录节标题(2026-10-19 用户裁「沿革只进 git」——原「必须有版本历史节」反转为此禁令,
# 同一组正则既拦新写也扫残留;R11 不再对其豁免,因为这类节本身即违规)。
# 只认标题行(`#`…`######` 或 Setext underline),正文行内提及这些词不误伤。
CHANGELOG_SECTION_RE = re.compile(r"^\s*#{1,6}\s*\S*?(版本历史|修订记录|变更记录|修改记录|更新记录|更新日志)")
FLOW_SECTION_RE = re.compile(r"(流程|生命周期)")
# R11 计数词漂移对照锚表(行协议 9 类 / 格面资源八型 / 权重 74)。
# 口径权威 = 各 owner GDD 正文;本表是其镜像,口径变动时同步此处(原 registry 登记处于 2026-10-19 注销)。
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 轨迹措辞豁免:留档/作废/退役/历史/旧 + 改型/随批/顺延(文档历史轨迹句「同批改型」)
R11_TRACE_RE = re.compile(r"(留档|作废|退役|历史|旧|改型|随批|顺延)")
DATE_KEYWORD_RE = re.compile(
r"[((][^()()]*\d{4}-\d{2}-\d{2}[^()()]*"
r"(评审|修订|拍板|裁决|核销|门禁|作废|批)[^()()]*[))]"
)
def parse(text):
"""按行解析:返回代码块行集合、表格块列表、散文段落列表。"""
code_lines, tables, paras = set(), [], []
cur_table, cur_para = [], []
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 stripped.startswith("|"):
if cur_para:
paras.append(" ".join(cur_para))
cur_para = []
cur_table.append(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 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, 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 changelog_hits(text):
"""R4 域:返回变更记录类标题行列表(任何标题级别、任何体裁文件一律不得存在)。"""
return [l for l in text.splitlines() if CHANGELOG_SECTION_RE.match(l)]
def lint_file(path):
text = Path(path).read_text(encoding="utf-8")
code_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 段落长度
fname = Path(path).name
ex = RULE_EXEMPT.get(fname, set()) # 规则粒度分域豁免(见 RULE_EXEMPT 注)
if str(path).replace("\\", "/").startswith(ART_DIR.replace("\\", "/") + "/"):
ex = ex | ART_DOMAIN_EXEMPT
for p in paras:
if "R1" in ex:
break
if len(p) > MAX_PARA:
issues.append(f"R1 散文段 {len(p)} 字(>{MAX_PARA}): {p[:50]}…")
# R2 表格占比已停用(见文件头注释)。
# R3 修订注记(代码块豁免;变更记录节已整体禁设,无豁免域)
body_no_code = "\n".join(l for l in text.splitlines() if l not in code_lines)
for m in (() if "R3" in ex else re.finditer(r"~~.+?~~", body_no_code)):
issues.append(f"R3 删除线残留: …{m.group(0)[:40]}…")
if "R3" not in ex and 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} 行附近)")
if "R3" not in ex:
for m in DATE_KEYWORD_RE.finditer(body_no_code):
issues.append(f"R3 正文修订注记: {m.group(0)[:50]}")
# R4 禁设变更记录节(2026-10-19 用户裁「沿革只进 git」——原「必须有版本历史节」反转;
# 一切体裁与登记处无豁免,标题行本身即违规,节内容不再逐行判)
for l in changelog_hits(text):
issues.append(f"R4 禁设变更记录节: {l.strip()[:50]}(沿革只进 git commit log)")
# R5 流程章节须含代码块图(清单节/词条节按 RULE_EXEMPT 豁免——节名含「流程」
# 即触发,但目检清单节与术语词条节不是流程描述,属节名误匹配)
cur_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 and "R5" not in ex:
issues.append(f"R5 「{cur_section}」节无流程图(须含代码块字符画/Mermaid)")
cur_section = stripped.lstrip("#").strip()
pending_flow = bool(FLOW_SECTION_RE.search(cur_section))
has_code = False
elif stripped.startswith("```") and cur_section:
has_code = True
if pending_flow and not has_code and "R5" not in ex:
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;轨迹措辞行豁免——变更记录节本身归 R4 禁设)
in_code11 = False
r11_active = "R11" not in ex
for raw in text.splitlines():
stripped = raw.strip()
if stripped.startswith("```"):
in_code11 = not in_code11
continue
if in_code11 or stripped.startswith(";") or R11_TRACE_RE.search(raw):
continue
if not r11_active:
continue
# yaml/yml 的 # 行内注释是登记轨迹,不入正文扫描
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}"(现行口径见 owner GDD 正文)'
)
# 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 and "R7" not in ex:
issues.append(f"R7 最长括号跨度 {longest_span} 字(>{MAX_PAREN_SPAN})——疑似审计注记挤进正文")
infos.append(f"最长括号跨度 {longest_span} 字")
return {"file": str(path), "issues": issues, "infos": infos}
def main():
ap = argparse.ArgumentParser(description="GDD lean 格式门禁")
ap.add_argument("files", nargs="*", help="待检查文件(默认 design/gdd/ 根层 GDD + roadmap.md)")
ap.add_argument("--changelog-only", action="store_true",
help="只跑 R4 变更记录节禁令,扫描面扩到全 design/ 树+roadmap.md")
ap.add_argument("--json", action="store_true", help="JSON 输出")
args = ap.parse_args()
if args.changelog_only:
targets = sorted(
p for p in list(Path("design").rglob("*.md")) + list(Path("reports").glob("*.md")) + [Path("roadmap.md")]
if p.is_file()
)
elif args.files:
targets = [Path(f) for f in args.files]
else:
root = Path(DEFAULT_DIR)
targets = sorted(
p for p in list(root.glob("*.md")) + [Path("roadmap.md")]
if p.name not in DEFAULT_EXCLUDE
and not re.search(r"review|report|handoff", p.name, re.I)
)
targets += sorted(
p for p in Path(ART_DIR).glob("*.md")
if not re.search(r"review|report|handoff", 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]
if args.changelog_only:
for r in results:
r["issues"] = [i for i in r["issues"] if i.startswith("R4")]
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']}")
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()