Files
gplan-2/tools/doc_ref_audit.py
T
DKChan e425ef1be0 chore(引用可核闸建档注册): 08d 入文档域闸序+三处 game/ 纯文字欠账清净
- tools/run_all_gates.sh:文档域闸序加「08d 引用可核」并补头注 --docs 清单(闸清单唯一事实源只写这一处)
- game/tests/.gates/baseline.json:手工加 08d 绿记录一条(13→14,其余条目原样保留),照 8f66ae15 之 08c 与 e8b6751b 之 06b 先例
- game/tests/static_scan.sh:100:S11 词表来源注释订正——「完美排雷」已于 2026-10-21 入退役词对照表,:122 追加行成冗余(sort -u 去重后行为不变,删它属改闸件词表本体,留待代码批,注释已写明)
- game/tests/README.md::58 套件数 26→35(按 _suites() manifest 实数);:81 Config/Data 证据落点 design/qa/ → 跑批回执+reports/;:92 verifier_batch 报告落点 design/qa/ → sim-data/(依 AGENTS 在册口径)
- doc_ref_audit.py:路径解析补「尾段匹配」一档,tests/run_all.gd/views/level_map_screen.gd 这类相对 game/ 工程的简写不再误判为路径不存在(实测纳入 reports/ 的红项 17→12,余下为历史件真实漂移,佐证排除面划法)
- 待办清单:引用可核注册欠账与 README 分级表 design/qa 两条完成即整条删除;game/ 域欠账条目收窄为 :122 冗余行一项,状态处记用户特批标记
零行为改动:无一行 GDScript、无表数值、无场景资源。验证:bash -n 两闸件语法 OK/baseline 合法 JSON/闸壳 --docs 自身退出码 0(含新注册 08d 与建档记录一致)/引用可核 64 件 84 处红项 0/流水线闸 PASS 69 条目。
2026-10-21 15:53:52 +08:00

230 lines
10 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/`——中间文档唯一暂存地(doc-conventions 第 11 节;`design/research/` 已于 2026-10-21 退役),
入内文件按「吸收即死」退役,历史件不回溯改,故不入本闸判据。
(2026-10-21 实测:把 reports/ 纳进来出 17 条红项/538 处引用,多为历史件里的行号漂移与当时合法的旧路径写法——
要收紧到「现行件才受指针核对」得先立分界,暂不扩。)
自检二道(防假绿):扫描文件数为零 ⇒ 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 于 2026-10-21 退役),全树入扫描面
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。
依序试:文档同目录相对路径 → 仓库根路径 → 按「尾段匹配」全库找(`tests/run_all.gd`
这类相对 game/ 工程的简写靠这一步命中,否则会被误判成路径不存在)。"""
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"
suffix = "/" + name
hits = sorted(p for p in index if p.endswith(suffix))
if len(hits) == 1:
return hits, "exact"
if len(hits) > 1:
return hits, "ambiguous"
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())