Files
gplan-2/tools/doc_link_audit.py
T

227 lines
9.8 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"""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())