chore: initial commit

This commit is contained in:
DKChan committed 2026-09-14 18:18:49 +08:00
commit ddd2960fa6
317 files changed
+41717

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
#!/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()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""PostToolUse hook: after Write/Edit on a design document, run gdd_lint and
feed failures back to the writing agent. Never crashes; best-effort."""
import json
import re
import subprocess
import sys
from pathlib import Path
try:
payload = json.load(sys.stdin)
except Exception:
sys.exit(0)
path = ""
try:
tool_input = payload.get("tool_input") or {}
path = tool_input.get("file_path") or ""
except Exception:
sys.exit(0)
if not path or not re.search(r"design[\\/].*\.md$", path.replace("\\", "/")):
sys.exit(0)
if re.search(r"review|report|legacy-", Path(path).name, re.I):
sys.exit(0)
# 与 gdd_lint.DEFAULT_EXCLUDE 同源:索引/概念层文档不在门禁默认范围,钩子同样跳过
if Path(path).name in {"game-concept.md", "game-pillars.md", "systems-index.md"}:
sys.exit(0)
# 设计推演件(analysis 两件套的另一半):以散文推演为主,不适用 GDD 格式门禁
norm = path.replace("\\", "/")
if Path(path).name == "analysis.md" or "/analysis/" in norm:
sys.exit(0)
if not Path("tools/gdd_lint.py").exists():
sys.exit(0)
try:
result = subprocess.run(
[sys.executable, "tools/gdd_lint.py", path, "--json"],
capture_output=True, text=True, timeout=30,
)
data = json.loads(result.stdout or "[]")
failures = [r for r in data if r.get("issues")]
if failures:
lines = ["[gdd_lint] 刚写入的文档未过格式门禁,请修复后再结束:"]
for r in failures:
for issue in r.get("issues", []):
lines.append(f" ! {issue}")
print("\n".join(lines), file=sys.stderr)
sys.exit(2)
except Exception:
pass
sys.exit(0)