Files
gplan-2/tools/doc_link_audit.py
T
DKChan f958a03f51 docs(research/退役批): design/research 全目录退役落地——中间文档唯一暂存地收敛为 reports/(用户 2026-10-20 令「design/ 只放持久化设计文档,过程件一律出 design/」的执行补完,2026-10-21 会话复述确认)
八件受版文件 git mv 进 reports/(一律先迁不删,删除归后续批或用户点名):提案三件(冒险前资源配比预设/效果引擎无状态化/武器锻造系统)、审计两件(代码术语审计/单测与验收门禁标准测试审计)、美术审计两件(cursor 会话存档 md + canvas 转 HTML 渲染件)、需求澄清-营地交互重做;未入库的 UI预览demo 三路对照件只搬盘不入库。

GDD 落文:锻造工坊.md「满级特效扩展位」节收下七条候选池(长剑破甲/长剑暴击面扩大/长枪先手连击/法杖开局魔法矢/轻甲灵巧/重甲反震/魔法护甲护盾),按现役 EFFECT_KINDS 与 settlement.tres:51/52 逐条复核实现代价分档,效果类型闭集口径取正文现行 31 种、不抄过程稿旧记 24 种;候选池定性为备用清单非承诺清单。

引用面换代 08(playnote 之外两处 ../research/ 改 ../../reports/)、待办清单四处、reports 八张卡片(跨目录前缀改同目录)、art-assets-checklist 与 sim-data/README 两处既存死指改「沿革查 git 提交史」、content-archive 出处句标注该目录已退役。迁入件内部相对链接随迁移重基准(../gdd/ → ../design/gdd/,playnote 与待办清单改回仓库根),并订正上一批遗留错误:两张迭代 30 卡片的 playnote 链接原写 ../../playnote.md(从 reports/ 出发指到仓库外),现改 ../playnote.md。

规则与机检:doc-conventions §11「暂存地非档案库」行改为单暂存地+禁在 design/ 下设过程件目录+图像载体与报告渲染件口径分列(禁图留 artwork/ 沿用 2026-09-27 用户裁;.html/.json 允许作为报告的一种=2026-10-21 用户裁,原「只放 .md」句系 10-17 建目录时顺带照搬、无单独点头记录);AGENTS Project Layout 两行同步;reports/README 补「设计讨论段过程稿」命名行。repo_structure_audit:DESIGN_SUBDIRS 去 research、RETIRED 加 design/research;doc_ref_audit 排除面清空(design 全树入扫描面);doc_term/doc_link 扫描域注释换代;三件样张脚本 OUT_REPORT 改落 reports/ 并补 os.makedirs(改前只对 OUT_PNG 建目录,目录缺失即 FileNotFoundError)。

变异验证:重建 design/research 并放文件 → 结构闸退出码 1、同时报「退役路径再现」与「子目录越界」两条;design/ 内放两条指向不存在文件的引用 → 引用可核机检报「路径不存在」红项 2(该闸暂未注册进闸壳,无基线记录必判变化);清场后结构闸 PASS。
2026-10-21 15:27:16 +08:00

227 lines
9.6 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/ 下的 .md,校验两类引用:
(a) markdown 链接 [文字](路径[#锚点]) —— 一律相对"链接所在文件自身目录"解析(标准
markdown 语义),跨目录用 ../。排除图片 ![...];目标里混入全角标点(偶发把全角括号
写进目标)时在首个全角标点处截断再校验。
(b) 反引号路径引用 `路径.扩展名`(扩展名限 md/yaml/yml/tres/gd/py/json/csv)——
只校验"路径形"(含 / 且不含空格、通配符)的引用;裸文件名(如表格里的
`reward.tres`、文档树里的 `棋盘生成.md`)是简称提及不是链接,跳过不校验。
解析顺序:所在文件目录 → 仓库根 → design/ → game/
(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/art、reports/
(历史素材/提案)不在扫描域,但作为链接目标被校验。排除 ``` 围栏代码块与 ; 开头注释行。
"""
import argparse
import json
import re
import sys
from pathlib import Path
DEFAULT_DIRS = ["design/gdd"]
PROTO_PREFIX = "game"
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())