#!/usr/bin/env python3 r"""doc_ref_audit.py — 文档内代码引用可核检查(机检) 职责:现行文档里凡写「某文件:第几行」,就该指得动。本工具核两件事: ①路径可解析——目标文件在 git 受控文件清单里存在; ②行号在界内——引用的行号不超过目标文件的实际行数。 动机:条目与规约常写 `board_view.gd:737` 这类指针,代码前进后行号漂移、文件迁移改名, 账面就与现实脱节而无人知(2026-10-21 迭代 32 收尾时实测抓到两例)。 扫描面:`design/` 现行文档树 + 四份根文件(`待办清单.md`/`体验验证清单.md`/`roadmap.md`/`AGENTS.md`)。 排除面:`reports/` 与 `design/research/`——两处是中间文档暂存地(doc-conventions 第 11 节), 入内文件按「吸收即死」退役,历史件不回溯改,故不入本闸判据。 自检二道(防假绿):扫描文件数为零 ⇒ FAIL;抽取到的引用数为零 ⇒ FAIL。 (正则被改写失手或路径写错时,检查会静默空转并报绿——与 static_scan 各段同规。) 判据分两档: 红(判 FAIL)=路径不存在/行号超界; 建议(只报告)=同名文件有多条路径需要人工定位/`--anchors` 的内容锚没在目标行附近找到。 用法: python tools/doc_ref_audit.py # 全扫,报告模式 python tools/doc_ref_audit.py --strict # 有红项时 exit 1(供门禁) python tools/doc_ref_audit.py --anchors # 追加内容锚核对(只报告,不判红) python tools/doc_ref_audit.py ... # 只扫指定文件 exit:0=全绿 / 1=有红项(--strict)/ 2=扫描面异常(目录缺失) """ import argparse import io import os import re import subprocess import sys from pathlib import Path REPO = Path(__file__).resolve().parents[1] SCAN_DIRS = ["design"] SCAN_EXCLUDE_DIRS = ["design/research"] ROOT_FILES = ["待办清单.md", "体验验证清单.md", "roadmap.md", "AGENTS.md"] # 引用形态:路径或文件名 + 扩展名 + 半/全角冒号 + 行号。反引号可包在中间或外面, # 因此先抓到扩展名为止,冒号允许紧跟或在反引号之后。 REF_RE = re.compile( r"([\w./一-鿿-]+\.(?:gd|tres|sh|py|json|md|cfg|yml|yaml|txt|png|html))" r"[`”』」]?\s*[::]\s*(\d+)" ) # 内容锚:同一行内的反引号片段或「」内文本,取长度 ≥4 的第一个非引用片段 ANCHOR_RE = re.compile(r"`([^`\n]{4,60})`|「([^」\n]{4,60})」") ANCHOR_WINDOW = 12 # 留档信号(与 doc_term_audit 同口径):同句带日期或退役/注销类说明时,指向已不存在的路径 # 属历史叙述而非现行指针,降为建议级报出、不判红。 ARCHIVE_RE = re.compile( r"\d{4}-\d{2}-\d{2}|退役|注销|已删|已废除|已废|废除|作废|历史|原名|改名|迁入|迁到|移到|迁至" ) def tracked_files(): out = subprocess.run( ["git", "-c", "core.quotepath=false", "-C", str(REPO), "ls-files"], capture_output=True, text=True, encoding="utf-8", errors="ignore", ) if out.returncode != 0: return None return {l.strip().strip('"').replace("\\", "/") for l in out.stdout.splitlines() if l.strip()} def collect_docs(explicit): if explicit: return [p for p in explicit if Path(p).is_file()] paths = [] for d in SCAN_DIRS: root = REPO / d if not root.is_dir(): return None for p in sorted(root.rglob("*.md")): rel = p.relative_to(REPO).as_posix() if any(rel.startswith(x + "/") for x in SCAN_EXCLUDE_DIRS): continue paths.append(rel) for f in ROOT_FILES: if (REPO / f).is_file(): paths.append(f) return paths def resolve(name, doc_rel, index, bybase): """返回 (命中路径列表, 解析方式)。方式=exact / ambiguous / none。""" name = name.strip("./") if "/" in name: for cand in ( os.path.normpath(os.path.join(os.path.dirname(doc_rel), name)).replace("\\", "/"), os.path.normpath(name).replace("\\", "/"), ): if cand in index: return [cand], "exact" return [], "none" cands = sorted(bybase.get(name, [])) if not cands: return [], "none" if len(cands) == 1: return cands, "exact" return cands, "ambiguous" def line_count(path, cache): if path not in cache: try: cache[path] = len(io.open(REPO / path, encoding="utf-8", errors="ignore").read().splitlines()) except OSError: cache[path] = 0 return cache[path] def anchor_of(line, name): """取同句里可当内容锚的片段:反引号或「」内、长度 ≥4、且自身不是文件路径。""" for m in ANCHOR_RE.finditer(line): frag = (m.group(1) or m.group(2)).strip() if name.split("/")[-1] in frag: continue if re.search(r"\.(gd|tres|sh|py|json|md|yaml|yml|html|png)$", frag): continue if re.fullmatch(r"[\d:~\-]+", frag): continue return frag return None def check(doc_rel, index, bybase, cache, anchors=False): reds, advises = [], [] text = io.open(REPO / doc_rel, encoding="utf-8", errors="ignore").read() for ln, line in enumerate(text.splitlines(), 1): for m in REF_RE.finditer(line): name = m.group(1).lstrip("①②③④⑤⑥⑦⑧⑨⑩〔[[((「」·、。,,;;") num = int(m.group(2)) hits, how = resolve(name, doc_rel, index, bybase) if how == "none": if ARCHIVE_RE.search(line): advises.append("%s:%d 引用已退役件 指 `%s`——同句带日期或退役/注销类说明,按留档处理" % (doc_rel, ln, name)) else: reds.append("%s:%d 引用可核/路径不存在 文档指 `%s`,git 受控文件清单里没有这件" % (doc_rel, ln, name)) continue if how == "ambiguous": advises.append("%s:%d 同名多路径 指 `%s`——命中 %d 件(%s),需写全路径" % ( doc_rel, ln, name, len(hits), ", ".join(hits[:3]))) target = hits[0] total = line_count(target, cache) if num > total: if ARCHIVE_RE.search(line): advises.append("%s:%d 行号已越界(留档句) 指 `%s:%d`,目标现 %d 行——同句带退役/日期说明" % ( doc_rel, ln, name, num, total)) else: reds.append("%s:%d 引用可核/行号超界 指 `%s:%d`,目标文件现只有 %d 行" % (doc_rel, ln, name, num, total)) continue if anchors: frag = anchor_of(line, name) if frag: lines = io.open(REPO / target, encoding="utf-8", errors="ignore").read().splitlines() lo, hi = max(0, num - 1 - ANCHOR_WINDOW), min(len(lines), num + ANCHOR_WINDOW) window = "\n".join(lines[lo:hi]) if frag not in window: advises.append("%s:%d 内容锚未命中 指 `%s:%d`,同句片段「%s」不在该线 ±%d 行内" % ( doc_rel, ln, os.path.basename(name), num, frag[:28], ANCHOR_WINDOW)) return reds, advises def main(): ap = argparse.ArgumentParser(description="文档内代码引用可核检查") ap.add_argument("files", nargs="*", help="指定文件;缺省扫 design/ 现行树+四份根文件") ap.add_argument("--strict", action="store_true", help="有红项时 exit 1") ap.add_argument("--anchors", action="store_true", help="追加内容锚核对(只报告)") args = ap.parse_args() index = tracked_files() if index is None: print("引用可核 FAIL:git ls-files 取不到受控文件清单(扫描面异常)") return 2 docs = collect_docs(args.files) if docs is None: print("引用可核 FAIL:扫描目录缺失(design/ 不在位)") return 2 if not docs: print("引用可核 FAIL:扫描面为空——路径写错导致的静默假绿") return 1 bybase = {} for p in index: bybase.setdefault(p.rsplit("/", 1)[-1], []).append(p) cache, all_reds, all_adv = {}, [], [] total_refs = 0 for doc in docs: reds, adv = check(doc, index, bybase, cache, anchors=args.anchors) total_refs += doc_ref_count(doc) all_reds += reds all_adv += adv if total_refs == 0: print("引用可核 FAIL:抽取到的引用数为零——正则或扫描面失手,属假绿防线") return 1 for r in all_reds: print(r) print("── 引用可核:扫描 %d 件文档 · 抽取引用 %d 处 · 红项 %d · 建议 %d" % ( len(docs), total_refs, len(all_reds), len(all_adv))) if all_adv: for a in all_adv[:25]: print(" (建议)" + a) if len(all_adv) > 25: print(" (建议)…另有 %d 条" % (len(all_adv) - 25)) return 1 if (args.strict and all_reds) else 0 def doc_ref_count(doc_rel): text = io.open(REPO / doc_rel, encoding="utf-8", errors="ignore").read() return sum(len(REF_RE.findall(l)) for l in text.splitlines()) if __name__ == "__main__": sys.exit(main())