八件受版文件 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。
227 lines
9.6 KiB
Python
227 lines
9.6 KiB
Python
#!/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())
|