Files
gplan-2/tools/gdd_lint.py
T
DKChan f406002766 docs(dev目录正名·engineering): design/dev 更名 design/engineering——名实对齐(目录实存=架构+五规约+引擎参考的工程知识域)
- git mv 15 件本体;13 个引用文件 34 处路径同步+熵增闸 DESIGN_SUBDIRS 白名单同批改判
- 白名单漏改时熵增闸当场真红(子目录越界拦截能力实录验证),同步后全绿
- game/tests 三件套注释按用户特别授权批后主区直改(不入本批);reports/research 暂存档按吸收即死原则不改
- 文档域闸全绿:gdd_lint/doc_link/doc_term/流水线闸/熵增闸
2026-10-20 05:33:34 +08:00

390 lines
18 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 使用)
规则(口径权威 = design/engineering/doc-conventions.md 第 5–6 节文档骨架与结构纪律):
R1 散文段落 ≤ 240 字(代码块/表格/列表/引用不计)
R2 表格字符占比(词条表分域豁免,见 R2_EXEMPT 注)
R3 正文禁止修订注记:~~删除线~~、"作废"、括号内"日期+评审/修订/拍板/裁决/核销"(版本历史节豁免;
登记处/清单按 RULE_EXEMPT 分域豁免)
R4 必须有「版本历史」节且含表格(登记处按 RULE_EXEMPT 分域豁免)
R5 流程/状态类章节必须含代码块图(字符画或 Mermaid);清单节/词条节按 RULE_EXEMPT 分域豁免
R6 表格列数一致(含转义 \| 识别)
R7 括号嵌套跨度 ≤ 80 字(长括号 = 审计注记挤进正文的信号;登记处/清单按 RULE_EXEMPT 分域豁免)
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 格"→十六格;版本历史/修订记录节、
留档/作废/退役/历史/旧/改型/随批/顺延等轨迹措辞行、`;` 注释行豁免(现行口径见 owner GDD 与 术语表)
"""
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",
"术语表.md", # 词条表:表格为主体形态,表格占比高是体裁本身
}
# 规则粒度分域豁免(2026-10-15 用户令「清单类文件与术语表不做 GDD 规范性检查」)。
# 原则:**按规则豁免,不按文件拉黑**——体裁规则(R3/R4/R5/R7)对登记处/清单/词条表
# 不适用,但机械可判的完整性规则(R6/R8/R9/R10/R11)一律不豁免,真缺陷不得藏起来。
# 各条理由:
# R3 正文禁修订注记——登记处/清单的条目「带日期+裁决来源」是设计目的本身
# (条目要能回答「这条谁在何时裁的」),禁它等于禁登记处的核心价值。
# R4 必须有版本历史节——06 是「解决即移除」的状态登记,条目不按版本累积,
# 强塞版本历史表反而制造第二套需同步的真相。
# R5 流程/状态类节须含代码块图——节名含「流程/生命周期」即触发,但 07 的
# 「㉖…全流程走查」「㉗…目检」是目检清单节、术语表的「开发流程」是词条节,
# 两者都不是流程描述,属节名误匹配。
# R7 括号跨度 ≤80 字——长括注在登记处/清单里是常态(现状+裁决+归属挤在一行)。
RULE_EXEMPT = {
"06-待办清单.md": {"R3", "R4", "R7"},
"07-体验验证清单.md": {"R3", "R5", "R7"},
"术语表.md": {"R5"},
}
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 计数词漂移对照锚表(行协议 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"(留档|作废|退役|历史|旧|改型|随批|顺延)")
# 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
ex = RULE_EXEMPT.get(fname, set()) # 规则粒度分域豁免(见 RULE_EXEMPT 注)
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 修订注记(版本历史节豁免;代码块豁免;登记处/清单按 RULE_EXEMPT 豁免)
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 (() 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 版本历史(登记处按 RULE_EXEMPT 豁免——条目「解决即移除」,不按版本累积)
if "R4" not in ex:
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 流程章节须含代码块图(清单节/词条节按 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)) and cur_section != VERSION_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;版本历史/修订记录节与轨迹措辞行豁免)
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 的 # 行内注释是登记轨迹,不入正文扫描
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, "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()