Files
gplan-2/tools/doc_ref_audit.py
T
DKChan 7493aebca8 chore(文档缺陷订正与机检补件): 四份根文件用词与死指订正+新增引用可核机检件
修文档缺陷四处:AGENTS.md「收口」×2、「不得」×3 换白话;roadmap.md:87「对拍」「标旗」换代;
reports/README.md 指向已迁移的 design/gdd/06-待办清单.md 改指仓库根;待办清单 iter19 遗留第⑦项
前半「数据配置.md:223 历史行死指」实查核销(该文件版本史行已随禁写变更记录规约消亡,现 212 行)。
补机检:tools/doc_ref_audit.py 核文档内「文件:行号」指针的目标存在性与行界,带扫描面为空与
引用数为零双自检;双向变异证据——注入不存在路径 EXIT=1、注入超界行号 EXIT=1、还原 EXIT=0。
doc_term 扫描面扩到四份根文件:扩面前实测这批文件自身含 3 处用语表废止词,先改现实再扩面,
扩面后「疑似现行」0、注入「完美排雷」见红 EXIT=1、还原 EXIT=0。
08d 引用可核暂不注册进闸壳:建档须改 game/tests/.gates/baseline.json,触 game/ 即要求代码域全量测试,
文档域批按测试两入口铁规零跑——注册欠账已登记待办清单。内容锚核对实测 81 处出 58 条未命中(约七成噪声),
按建议级交付不判红。文档域闸全绿。
2026-10-21 05:51:17 +08:00

220 lines
9.3 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_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 <file.md> ... # 只扫指定文件
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())