修文档缺陷四处: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 条未命中(约七成噪声), 按建议级交付不判红。文档域闸全绿。
220 lines
9.3 KiB
Python
220 lines
9.3 KiB
Python
#!/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())
|