#!/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 ... # 只扫指定文件 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"(? 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())