用户令四条:清单类与术语表免 GDD 规范性检查、退役词统一处理、文件头另处解决、 无使用的探针全删。 - tools/gdd_lint.py:新增 RULE_EXEMPT(规则粒度分域豁免,**按规则不按文件**)—— 06 免 R3/R4/R7、07 免 R3/R5/R7、术语表免 R2/R5。 理由逐条写进代码注释:R3 禁修订注记与「登记处条目要能回答谁在何时裁的」直接冲突; R4 版本历史节与 06「解决即移除」的状态登记体裁冲突;R5 是节名含「流程」即触发的误匹配 (07 的「㉖全流程走查」是目检清单节、术语表「开发流程」是词条节); R7 长括注在登记处是常态。 **机械可判的完整性规则 R6/R8/R9/R10/R11 一律不豁免**,真缺陷不得藏起来。 - design/gdd/术语表.md:版本号撞号订正(10-12 那条 v1.2→v1.9,两条 10-13 顺延 v1.10/v1.11), 按真实时序重排。原撞号源于 2026-10-11 与 10-12 两批各写一个 v1.2。 - tools/doc_term_audit.py:五组退役词按语境豁免而非改正文—— 充能(秘宝主动件现行机制,扩 exempt 信号)、可选类道具与雾格(否定式引用「现行池内无可选类」 「不做清单」——替换会毁掉句意)、伏击/heavy_multiplier(battle.md 占位表里的退役敌人 id, 现行规格在 敌人系统.md)、拔旗(06 待办「件派生动词白名单」是待评估设计意图非在用机制)。 疑似现行 6 处 → 0。 - 孤儿探针退役:ui_layout_probe 与 ui_audit_probe 共 6 文件(.gd/.uid/.tscn)删除—— 两者均无任何闸脚本执行(全仓仅注释与历史报告提及)。 连带清理 render_gate.sh 头注、board_view.gd cell_size 注释、game/README.md roster 行。 **遗留**:ui_layout_probe 原承担 1920 档布局机检面,现无承载,已在 render_gate 头注标明待补。 - roadmap.md 迭代循环第 4/5 条:轻闸/重闸改指 run_all_gates.sh,闸清单不再散落第四处。 闸况:9 道绿(含 gdd_lint / doc_link / doc_term 三闸全绿);static_scan S10 文件头 125 处 按用户令不在本批处理,已落 baseline.json 作为存量红。
390 lines
18 KiB
Python
390 lines
18 KiB
Python
#!/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 表格字符占比(词条表分域豁免,见 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 格"→十六格;版本历史/修订记录节、
|
||
留档/作废/退役/历史/旧/改型/随批/顺延等轨迹措辞行、`;` 注释行豁免(现行口径见 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",
|
||
"术语表.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 计数词漂移对照锚表。事实源 = 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
|
||
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 的 # 行内注释是登记轨迹(如 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 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()
|