feat(tools/docs): 文档一致性三项基建批(2026-09-20 用户拍板三选全做,三子 Agent 并行+主会话收口)——①doc_link_audit.py 链接健康检查(226 行:markdown 相对链接相对所在目录解析+反引号路径形多基解析〔所在目录→仓库根→design→slice〕+中文锚 GFM 规则命中+--known 白名单/--strict 门禁/--json;自测 16/16;全仓 771 链接扫出 11 真死链 0 误报);②gdd_lint 新增 R10 版本历史版本号唯一性(版本历史表首列查重)+R11 计数词漂移对照(锚表指路 registry:359/:314/:327,版本历史节+轨迹词豁免,yaml # 注释剥离)——R10 上线即抓到战斗结算 v0.16 真实撞号(充能退役批 vs 甲效矩阵批)R11 抓到 06 七型×4/interaction-patterns 15 类×2;③存量清理=06 七型×4→八型(含枚举补第8型+版本号成对修正)+interaction-patterns 15 类×2→9 类+02-系统清单 archive 死链降纯文字+战斗结算 v0.16 撞号顺延 v0.22(后落籍者避让,照 v0.19 先例)+地形变异战斗结算链接目录纠正×2+06 待办自指链接多退一级修正+8 处未建 UX 文档引用入 known 白名单(player-journey/node-reward/accessibility-requirements/level-clear 欠账);验证=doc_link_audit --strict PASS(771 链接 0 死链 7 已知)+R10/R11 全仓 0 命中+R1-R9 与 master 基线一致(8 文件存量)+doc_term_audit 疑似 3 留档不变
This commit is contained in:
1 parent
ca6465aa26
commit
a2e1ab05dd
8 files changed
+319
-18
No files matched your search
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""doc_link_audit.py — design 文档链接健康检查
|
||||
|
||||
扫 design/gdd/、design/registry/、design/ux/ 下的 .md 与 .yaml,校验两类引用:
|
||||
(a) markdown 链接 [文字](路径[#锚点]) —— 一律相对"链接所在文件自身目录"解析(标准
|
||||
markdown 语义),跨目录用 ../。排除图片 ![...];目标里混入全角标点(偶发把全角括号
|
||||
写进目标)时在首个全角标点处截断再校验。
|
||||
(b) 反引号路径引用 `路径.扩展名`(扩展名限 md/yaml/yml/tres/gd/py/json/csv)——
|
||||
只校验"路径形"(含 / 且不含空格、通配符)的引用;裸文件名(如表格里的
|
||||
`reward.tres`、文档树里的 `棋盘生成.md`)是简称提及不是链接,跳过不校验。
|
||||
解析顺序:所在文件目录 → 仓库根 → design/ → prototypes/dungeon-sweeper-vertical-slice/
|
||||
(data/tables/*.tres 类指路代码侧的惯用写法实际落在 slice 下;带 ./ ../ 前缀的只按
|
||||
所在文件目录解析,不做多基猜测)。
|
||||
锚点校验:目标为 .md 时,把其标题行转成 GFM 中文锚(去空格、去冒号等标点、小写英文,
|
||||
仅保留字母/数字/汉字),链接 #锚点 与之精确匹配即命中。
|
||||
|
||||
用法:
|
||||
python tools/doc_link_audit.py # 扫默认范围,报告模式(exit 0)
|
||||
python tools/doc_link_audit.py --strict # 有非已知死链 exit 1(供门禁/CI)
|
||||
python tools/doc_link_audit.py --known tools/doc_link_known.txt
|
||||
# 已知死链清单,每行 <文件相对路径>|<目标>,
|
||||
# 命中的归"已知"类不算失败
|
||||
python tools/doc_link_audit.py <file.md> ... # 只扫指定文件
|
||||
python tools/doc_link_audit.py --json # 机器可读
|
||||
|
||||
口径:只扫 design/gdd/ design/registry/ design/ux/——design/art、design/research
|
||||
(历史素材/提案)不在扫描域,但作为链接目标被校验。排除 ``` 围栏代码块与 ; 开头注释行。
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_DIRS = ["design/gdd", "design/registry", "design/ux"]
|
||||
PROTO_PREFIX = "prototypes/dungeon-sweeper-vertical-slice"
|
||||
|
||||
ALLOWED_EXTS = ("md", "yaml", "yml", "tres", "gd", "py", "json", "csv")
|
||||
MD_LINK_RE = re.compile(r"(?<!!)\[[^\]]*\]\(([^)\s]+)\)") # (?<!!) 排除图片
|
||||
BACKTICK_RE = re.compile(r"`([^`\n]+)`")
|
||||
HEADING_RE = re.compile(r"^#{1,6}\s+(.*?)\s*$")
|
||||
EXT_RE = re.compile(r"\.(" + "|".join(ALLOWED_EXTS) + r")$", re.IGNORECASE)
|
||||
SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE) # http:/mailto: 等外链
|
||||
# 链接目标里不该出现的全角标点/强调符——出现即在此截断(源文偶发把全角括号写进目标)
|
||||
DEST_CUT_RE = re.compile(r"[()【】,、;:“”‘’·…—*]")
|
||||
|
||||
|
||||
def anchor_slug(heading):
|
||||
"""GFM 中文锚:仅保留字母/数字/汉字,小写英文。"""
|
||||
return "".join(ch.lower() for ch in heading if ch.isalnum())
|
||||
|
||||
|
||||
def readable_lines(text):
|
||||
"""逐行产出,跳过 ``` 围栏代码块;不做行过滤,交由调用方按需排除注释行。"""
|
||||
fenced = False
|
||||
for lineno, line in enumerate(text.split("\n"), 1):
|
||||
if line.strip().startswith("```"):
|
||||
fenced = not fenced
|
||||
continue
|
||||
if fenced:
|
||||
continue
|
||||
yield lineno, line
|
||||
|
||||
|
||||
class Repo:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self._heads = {} # Path(resolve) -> set(slug)
|
||||
|
||||
def headings(self, path):
|
||||
key = path.resolve()
|
||||
if key not in self._heads:
|
||||
slugs = set()
|
||||
try:
|
||||
text = key.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
except OSError:
|
||||
text = ""
|
||||
for _, line in readable_lines(text):
|
||||
m = HEADING_RE.match(line)
|
||||
if m:
|
||||
slugs.add(anchor_slug(m.group(1)))
|
||||
self._heads[key] = slugs
|
||||
return self._heads[key]
|
||||
|
||||
def resolve_markdown(self, src, target):
|
||||
"""markdown 链接目标:一律相对所在文件目录,不做多基猜测。"""
|
||||
return src.parent / target
|
||||
|
||||
def resolve_backtick(self, src, target):
|
||||
"""反引号路径引用:带 ./ ../ 前缀只按所在文件目录;否则按序试多基目录。"""
|
||||
norm = target.replace("\\", "/")
|
||||
if norm.startswith(("./", "../")):
|
||||
bases = [src.parent]
|
||||
else:
|
||||
bases = [src.parent, self.root, self.root / "design", self.root / PROTO_PREFIX]
|
||||
for base in bases:
|
||||
p = base / norm
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def scan(repo, paths, comment_re=re.compile(r"^\s*;")):
|
||||
findings = [] # 死链/锚点未命中
|
||||
counts = {"markdown": 0, "anchor": 0, "backtick": 0}
|
||||
for p in paths:
|
||||
text = p.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
for lineno, line in readable_lines(text):
|
||||
if comment_re.match(line):
|
||||
continue
|
||||
# (a) markdown 链接
|
||||
for m in MD_LINK_RE.finditer(line):
|
||||
raw = m.group(1)
|
||||
target = DEST_CUT_RE.split(raw, 1)[0]
|
||||
if not target or SCHEME_RE.match(target):
|
||||
continue
|
||||
path, sep, frag = target.partition("#")
|
||||
if not path: # 同页锚点
|
||||
counts["anchor"] += 1
|
||||
if frag not in repo.headings(p):
|
||||
findings.append(_f(p, lineno, "anchor", target, "锚点未命中", line))
|
||||
continue
|
||||
counts["markdown"] += 1
|
||||
tp = repo.resolve_markdown(p, path)
|
||||
if not tp.exists():
|
||||
findings.append(_f(p, lineno, "markdown", target, "目标不存在", line))
|
||||
elif frag:
|
||||
counts["anchor"] += 1
|
||||
if tp.suffix.lower() == ".md" and frag not in repo.headings(tp):
|
||||
findings.append(_f(p, lineno, "anchor", target, "锚点未命中", line))
|
||||
# (b) 反引号路径引用(只校验路径形)
|
||||
for m in BACKTICK_RE.finditer(line):
|
||||
raw = m.group(1).strip()
|
||||
if "/" not in raw or " " in raw or "*" in raw or "?" in raw:
|
||||
continue
|
||||
if not EXT_RE.search(raw) or SCHEME_RE.match(raw):
|
||||
continue
|
||||
counts["backtick"] += 1
|
||||
if repo.resolve_backtick(p, raw) is None:
|
||||
findings.append(_f(p, lineno, "backtick", raw, "目标不存在", line))
|
||||
findings.sort(key=lambda f: (f["file"], f["line"]))
|
||||
return findings, counts
|
||||
|
||||
|
||||
def _f(p, lineno, kind, target, problem, line):
|
||||
return {
|
||||
"file": p.as_posix(),
|
||||
"line": lineno,
|
||||
"kind": kind,
|
||||
"target": target,
|
||||
"problem": problem,
|
||||
"text": line.strip()[:160],
|
||||
}
|
||||
|
||||
|
||||
def load_known(path):
|
||||
"""已知死链清单:每行 <文件相对路径>|<目标>;# 与空行忽略。"""
|
||||
known = set()
|
||||
if not path:
|
||||
return known
|
||||
for line in Path(path).read_text(encoding="utf-8").replace("\r\n", "\n").split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
file, sep, target = line.partition("|")
|
||||
if sep:
|
||||
known.add((file.strip().replace("\\", "/").lstrip("./"), target.strip()))
|
||||
return known
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="design 文档链接健康检查")
|
||||
ap.add_argument("files", nargs="*", help="指定文件;缺省扫默认目录")
|
||||
ap.add_argument("--strict", action="store_true", help="有非已知死链时 exit 1")
|
||||
ap.add_argument("--known", help="已知死链清单(每行 <文件相对路径>|<目标>)")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.files:
|
||||
paths = [Path(f) for f in args.files]
|
||||
else:
|
||||
paths = []
|
||||
for d in DEFAULT_DIRS:
|
||||
paths += sorted(Path(d).rglob("*.md"))
|
||||
paths += sorted(Path(d).rglob("*.yaml"))
|
||||
paths = [p for p in paths if p.is_file()]
|
||||
if not paths:
|
||||
print("扫描域为空(未找到任何 .md/.yaml)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
repo = Repo(Path("."))
|
||||
findings, counts = scan(repo, paths)
|
||||
known = load_known(args.known)
|
||||
dead = [f for f in findings if (f["file"], f["target"]) not in known]
|
||||
known_dead = [f for f in findings if (f["file"], f["target"]) in known]
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({
|
||||
"scanned": len(paths),
|
||||
"counts": {"markdown": counts["markdown"], "backtick": counts["backtick"],
|
||||
"anchor": counts["anchor"], "total": sum(counts.values())},
|
||||
"dead": dead,
|
||||
"known_dead": known_dead,
|
||||
"dead_count": len(dead),
|
||||
"known_count": len(known_dead),
|
||||
}, ensure_ascii=False, indent=1))
|
||||
else:
|
||||
total = sum(counts.values())
|
||||
print(f"扫描 {len(paths)} 个文件 · 链接 {total} 处"
|
||||
f"(markdown {counts['markdown']} + 反引号路径 {counts['backtick']}"
|
||||
f" + 锚点 {counts['anchor']})· 死链 {len(dead)} 处 · 已知死链 {len(known_dead)} 处")
|
||||
for title, items in (("死链", dead), ("已知死链(不算失败)", known_dead)):
|
||||
if not items:
|
||||
continue
|
||||
print(f"\n【{title}】×{len(items)}")
|
||||
by_file = {}
|
||||
for f in items:
|
||||
by_file.setdefault(f["file"], []).append(f)
|
||||
for file, fs in sorted(by_file.items()):
|
||||
for f in fs:
|
||||
print(f" {file}:{f['line']} [{f['kind']}] {f['target']}({f['problem']})")
|
||||
return 1 if (args.strict and dead) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
design/ux/battle.md|design/player-journey.md
|
||||
design/ux/battle.md|design/ux/node-reward.md
|
||||
design/ux/battle.md|design/accessibility-requirements.md
|
||||
design/ux/battle.md|design/ux/level-clear.md
|
||||
+79
-8
@@ -17,6 +17,11 @@ r"""gdd_lint.py — GDD lean 格式门禁
|
||||
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
|
||||
@@ -34,8 +39,27 @@ MAX_PAREN_SPAN = 80
|
||||
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"(评审|修订|拍板|裁决|核销|门禁|作废|批)[^()()]*[))]"
|
||||
@@ -43,9 +67,12 @@ DATE_KEYWORD_RE = re.compile(
|
||||
|
||||
|
||||
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()
|
||||
@@ -56,8 +83,8 @@ def parse(text):
|
||||
paras.append(" ".join(cur_para))
|
||||
cur_para = []
|
||||
if cur_table:
|
||||
tables.append(cur_table)
|
||||
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()
|
||||
@@ -68,10 +95,12 @@ def parse(text):
|
||||
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:
|
||||
tables.append(cur_table)
|
||||
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
|
||||
@@ -90,8 +119,8 @@ def parse(text):
|
||||
if cur_para:
|
||||
paras.append(" ".join(cur_para))
|
||||
if cur_table:
|
||||
tables.append(cur_table)
|
||||
return code_lines, version_lines, tables, paras
|
||||
(version_tables if cur_table_hist else tables).append(cur_table)
|
||||
return code_lines, version_lines, tables, paras, version_tables
|
||||
|
||||
|
||||
def unescaped_pipes(line):
|
||||
@@ -118,7 +147,7 @@ MAX_NODE_LINES = 80
|
||||
|
||||
def lint_file(path):
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
code_lines, version_lines, tables, paras = parse(text)
|
||||
code_lines, version_lines, tables, paras, version_tables = parse(text)
|
||||
issues, infos = [], []
|
||||
|
||||
# R9 一页纸节点:每个标题的直接内容 ≤ MAX_NODE_LINES 行
|
||||
@@ -177,6 +206,28 @@ def lint_file(path):
|
||||
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
|
||||
@@ -226,6 +277,26 @@ def lint_file(path):
|
||||
)
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user