From a2297c21e860b7d5d77b96d3a58f15d21934f6ac Mon Sep 17 00:00:00 2001 From: anonsaber Date: Mon, 28 Sep 2026 21:58:07 +0800 Subject: [PATCH] =?UTF-8?q?tools:=20=E5=9B=BA=E5=8C=96=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=99=A8=E4=B8=BA=20GATE-DOCS=EF=BC=88?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E5=8F=AA=E5=AD=98=E5=9C=A8=E4=BA=8E=20/tmp?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E8=AF=81=E4=B8=8D=E5=8F=AF=E5=A4=8D=E7=8E=B0?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个校验器原先散落在 /tmp,本会话 /tmp 就被清过一次(validate_docs.py 中途消失),仓库里没有任何引用——「222 个行号锚点 0 error」这个结论在脚本 被清之后就无从再证,属于静默漂移。现固化进 scripts/docs_check/: - validate_docs.py 代码块围栏闭合 / 行内反引号偶数 / 本地链接与 #锚点可达 - check_tables.py 表格表头、分隔行、数据行列数一致(行内代码里的 | 不计) - audit_anchors.py 每个 foo.rs:N / foo.rs:N-M 锚点指向存在且非空的目标行 - check_sec_refs.py 每个 §N.N 交叉引用解析到归属文档的真实标题 - run_all.sh 驱动脚本,任一失败即 exit 1,仿 GATE-P0 的用法 改动说明(相对 /tmp 原版) - ROOT 由硬编码绝对路径改为 Path(__file__).resolve().parents[2],干净 clone 即可运行,无需改任何路径。四个文件已 diff 确认仅此一处差异。 - audit_anchors.py 加了 docstring 写明能力边界:只证明锚点可达,不证明该行 内容与所述一致,无法发现「指向错行」的锚点。 - run_all.sh 用 grep -m1 '^===' 取摘要而非 head -1:validate_docs.py 摘要前 带一个空行(原版自带瑕疵,保留原样以免偏离已验证版本),按位置取会取错。 双向验证:干净树 PASS/exit 0;注入 worker.rs:99999 与 §99.9 后分别被 audit_anchors 与 check_sec_refs 报出、exit 1;git checkout 还原后回到 PASS。 AGENTS.md 记录跑法与「可达 ≠ 准确」的区分。GATE-P0(cargo)不受影响,本次 无任何 src/ 改动。 --- AGENTS.md | 6 ++ scripts/docs_check/audit_anchors.py | 60 ++++++++++++++++ scripts/docs_check/check_sec_refs.py | 103 +++++++++++++++++++++++++++ scripts/docs_check/check_tables.py | 54 ++++++++++++++ scripts/docs_check/run_all.sh | 53 ++++++++++++++ scripts/docs_check/validate_docs.py | 85 ++++++++++++++++++++++ 6 files changed, 361 insertions(+) create mode 100755 scripts/docs_check/audit_anchors.py create mode 100755 scripts/docs_check/check_sec_refs.py create mode 100755 scripts/docs_check/check_tables.py create mode 100755 scripts/docs_check/run_all.sh create mode 100755 scripts/docs_check/validate_docs.py diff --git a/AGENTS.md b/AGENTS.md index 329f2f2..e1fbedc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,12 @@ > `docs/deployment.md` §2.1、`docs/reference.md` §3/§4、`README` 中英双版;⑥ 落地 `/search` 与两项可靠性修复, > `docs/design.md` §5.7、`docs/reference.md` §6.3/§6.4、`docs/roadmap.md` 代码缺口区同步更新。 > 各文档的行号锚点以代码 `bfe0fd8` 为准,代码变动后须同步重锚。 +> +> **改完文档或动了 `src/` 行号,先跑 `bash scripts/docs_check/run_all.sh`**(GATE-DOCS,4 个检查全 exit 0 才算过): +> `validate_docs.py`(代码块围栏/行内反引号/本地链接与锚点可达)、`check_tables.py`(表格列数一致,行内代码里的 `|` 不计)、 +> `audit_anchors.py`(`foo.rs:N` 行号锚点存在且非空行)、`check_sec_refs.py`(`§N.N` 交叉引用指向真实标题)。 +> **注意 `audit_anchors.py` 只证明锚点可达,不证明该行内容与所述一致**——它无法发现「指向了错行」的锚点,那需要人工读目标行。 +> 引「0 errors」时请区分这两种含义。校验器自身随仓库版本化,无需 `/tmp` 副本。 --- diff --git a/scripts/docs_check/audit_anchors.py b/scripts/docs_check/audit_anchors.py new file mode 100755 index 0000000..1b04fc6 --- /dev/null +++ b/scripts/docs_check/audit_anchors.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Check every `foo.rs:N` / `foo.rs:N-M` line anchor in the docs. + +LIMITATION: this only proves the cited line *exists and is not blank*. It does +not prove the line contains what the prose claims about it. A doc can pass this +audit with anchors that point at the wrong line -- a human must read the target +line to catch that. Keep this in mind when quoting "0 errors" as proof of +accuracy: it proves reachability, not correctness. +""" +import re, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', + 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', 'docs/roadmap.md'] + +REF = re.compile(r'(?:(src|cloudflare-worker/src)/)?([A-Za-z0-9_]+)\.(?:rs|js):(\d+)(?:\s*[,–\-]\s*(\d+))?') + +errors = [] +checked = 0 +for d in DOCS: + lines = (ROOT / d).read_text().splitlines() + in_fence = False + for i, line in enumerate(lines, 1): + if re.match(r'^\s*(```|~~~)', line): + in_fence = not in_fence + continue + if in_fence: + continue + for m in REF.finditer(line): + sub, base, a, b = m.group(1), m.group(2), m.group(3), m.group(4) + if sub == 'cloudflare-worker/src': + p = ROOT / sub / f'{base}.js' + elif sub == 'src' or sub is None: + p = ROOT / 'src' / f'{base}.rs' + if not p.exists() and sub is None: + q = ROOT / 'cloudflare-worker/src' / f'{base}.js' + if q.exists(): + p = q + else: + p = ROOT / sub / f'{base}.rs' + if not p.exists(): + errors.append(f'{d}:{i} unknown file {p.name}') + continue + src = p.read_text().splitlines() + hi = int(b) if b else int(a) + if hi > len(src): + errors.append(f'{d}:{i} {p.name}:{a}-{b} out of range (file has {len(src)} lines)') + continue + lo = int(a) + targets = {lo} | ({hi} if b else set()) + for n in sorted(targets): + checked += 1 + if not src[n - 1].strip(): + errors.append(f'{d}:{i} {p.name}:{a}{"-" + b if b else ""} -> line {n} is blank') + +print(f'=== ANCHOR AUDIT ({checked} line-refs checked, {len(errors)} errors) ===') +for e in errors: + print(e) +sys.exit(1 if errors else 0) diff --git a/scripts/docs_check/check_sec_refs.py b/scripts/docs_check/check_sec_refs.py new file mode 100755 index 0000000..864c250 --- /dev/null +++ b/scripts/docs_check/check_sec_refs.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Check that every intra-document `§X.Y` / `§X` reference resolves to a real +heading in the same file. + +Cross-document refs (e.g. `docs/design.md §5.7` or `design.md §5.7`) are also +checked, against the *target* file's heading set. + +This is the gap that let `docs/design.md` §597/§658 and `docs/roadmap.md` §24 +point at a design.md §3.4 that never existed. +""" +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md", + "docs/deployment.md", "docs/reference.md", "docs/retired.md", + "docs/roadmap.md"] + +HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s") +# §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer +SEC = re.compile(r"§(\d+(?:\.\d+)?)") +# an explicit target file sitting immediately before the §ref +TARGET_BEFORE = re.compile( + r"(?:docs/)?(AGENTS\.md|README\.md|README\.zh-CN\.md|design\.md|" + r"deployment\.md|reference\.md|retired\.md|roadmap\.md)\s*[`\s]*" + r"§(\d+(?:\.\d+)?)") + + +def headings(path): + """Return the set of section numbers present as headings.""" + out = set() + with open(path, encoding="utf-8") as fh: + for line in fh: + m = HEADING.match(line) + if m: + out.add(m.group(2).rstrip(".")) + return out + + +def main(): + names = {os.path.basename(p): p for p in DOCS} + heads = {os.path.basename(p): headings(os.path.join(ROOT, p)) for p in DOCS} + errors = [] + checked = 0 + + for rel in DOCS: + base = os.path.basename(rel) + full = os.path.join(ROOT, rel) + with open(full, encoding="utf-8") as fh: + for lineno, line in enumerate(fh, 1): + if line.lstrip().startswith("#"): + continue + # Resolve against the current file by default; a table row + # whose first cell names a document, or an earlier explicit + # `foo.md §N`, overrides it for the rest of the line. + target = base + override_pos = -1 + if line.lstrip().startswith("|"): + first_cell = line.split("|")[1:2] + m_cell = re.search(r"(?:docs/)?([A-Za-z0-9_.-]+\.md)", + first_cell[0]) if first_cell else None + if m_cell: + target = m_cell.group(1) + override_pos = line.find(m_cell.group(1)) + for m in TARGET_BEFORE.finditer(line): + target = m.group(1) + override_pos = m.end() + checked += 1 + if m.group(2) not in heads.get(target, set()): + errors.append( + f"{rel}:{lineno}: §{m.group(2)} does not exist " + f"in {target}") + for m in SEC.finditer(line): + # RFC-style three-part numbering (`§7.2.3`) is external, + # not a doc reference. + if line[m.end():m.end() + 1] == ".": + continue + if m.start() <= override_pos: + # already consumed as a cross-doc ref + continue + num = m.group(1) + # chained refs (`§2.3、§3.2、§5.7`) inherit `target` + pos = m.start() + prefix = line[:pos] + if re.search(r"(?:\.md)[`\s]*$", prefix): + target = os.path.basename( + re.search(r"([A-Za-z0-9_.-]+\.md)[`\s]*$", + prefix).group(1)) + checked += 1 + if num not in heads.get(target, set()): + errors.append( + f"{rel}:{lineno}: §{num} does not exist in {target}") + + print(f"=== SECTION-REF AUDIT ({checked} refs checked, " + f"{len(errors)} errors) ===") + for e in errors: + print(" " + e) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/docs_check/check_tables.py b/scripts/docs_check/check_tables.py new file mode 100755 index 0000000..c54b11d --- /dev/null +++ b/scripts/docs_check/check_tables.py @@ -0,0 +1,54 @@ +import re, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', + 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', 'docs/roadmap.md'] + +CODE = re.compile(r'`[^`\n]*`') +SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$') + +def cells(line): + """Split a table row into cells, ignoring pipes inside inline code spans.""" + blanks = [' ' * len(s) for s in CODE.findall(line)] + stripped = CODE.sub(lambda m: blanks.pop(0), line) + s = stripped.strip() + if s.startswith('|'): + s = s[1:] + if s.endswith('|'): + s = s[:-1] + return s.split('|') + +errors = [] +for d in DOCS: + lines = (ROOT / d).read_text().splitlines() + in_fence = False + i = 0 + while i < len(lines): + if re.match(r'^\s*(```|~~~)', lines[i]): + in_fence = not in_fence + i += 1 + continue + if in_fence: + i += 1 + continue + # detect table start: a row containing a pipe, followed by a separator row + if '|' in lines[i] and i + 1 < len(lines) and SEP.match(lines[i + 1]): + n_head = len(cells(lines[i])) + n_sep = len(cells(lines[i + 1])) + if n_head != n_sep: + errors.append(f'{d}:{i+1} header/separator column mismatch: {n_head} vs {n_sep}') + j = i + 2 + while j < len(lines) and '|' in lines[j] and not re.match(r'^\s*$', lines[j]): + n = len(cells(lines[j])) + if n != n_head: + errors.append(f'{d}:{j+1} row has {n} cells, header has {n_head}: {lines[j].strip()[:90]}') + j += 1 + i = j + continue + i += 1 + +print(f'=== TABLE STRUCTURE ({len(errors)} mismatches) ===') +for e in errors: + print(e) +sys.exit(1 if errors else 0) diff --git a/scripts/docs_check/run_all.sh b/scripts/docs_check/run_all.sh new file mode 100755 index 0000000..5012a54 --- /dev/null +++ b/scripts/docs_check/run_all.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# GATE-DOCS: run every docs-check validator and exit non-zero if any fails. +# +# Run after editing docs or after a code commit that moves line numbers: +# bash scripts/docs_check/run_all.sh +# +# What each check proves -- read before quoting the totals as "accurate": +# +# validate_docs markdown fences balanced, inline backticks even, and every +# local link + #anchor target exists. +# check_tables markdown tables: header/separator/row column counts agree, +# pipes inside inline code spans are not counted. +# audit_anchors every `foo.rs:N` / `foo.rs:N-M` anchor in the docs points at +# a line that EXISTS and is not blank. LIMITATION: it does not +# prove the line says what the prose claims. A doc can pass +# with anchors on the wrong line -- someone must read them. +# check_sec_refs every "§N.N" cross-reference resolves to a real heading in +# the document it is attributed to. +# +# audit_anchors and check_sec_refs only scan prose: lines inside fenced code +# blocks are skipped, so examples and tables holding code do not raise noise. +# +# Root is derived from this file's own location, so the whole suite works from a +# clean clone without editing any path. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +rc=0 +fail=0 + +for script in validate_docs check_tables audit_anchors check_sec_refs; do + out="$(python3 "$here/$script.py" 2>&1)" + code=$? + # Each check prints "=== NAME (n errors) ===" as its summary, but not + # necessarily as its first line (validate_docs.py leads with a blank line), + # so match on the "===" prefix instead of assuming a position. + summary="$(printf '%s\n' "$out" | grep -m1 '^===' || true)" + printf '%s\n' "${summary:-$script: no summary line emitted}" + if [ "$code" -ne 0 ]; then + printf '%s\n' "$out" | grep -v '^===' | grep -v '^[[:space:]]*$' \ + | sed 's/^/ /' || true + rc=1 + fail=$((fail + 1)) + fi +done + +if [ "$fail" -eq 0 ]; then + echo "GATE-DOCS: PASS (4/4 checks clean)" +else + echo "GATE-DOCS: FAIL ($fail check(s) reported errors)" +fi +exit "$rc" diff --git a/scripts/docs_check/validate_docs.py b/scripts/docs_check/validate_docs.py new file mode 100755 index 0000000..43e598e --- /dev/null +++ b/scripts/docs_check/validate_docs.py @@ -0,0 +1,85 @@ +import re, sys +from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] +DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', + 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', 'docs/roadmap.md'] +errors = [] + +def slug(t): + t = re.sub(r'[^\w\s\-]', '', t.strip().lower(), flags=re.UNICODE) + return t.replace(' ', '-') + +def heading_set(text): + return set(slug(m.group(2)) for m in re.finditer(r'^(#{1,6})\s+(.*)$', text, re.M)) + +HEADS = {d: heading_set((ROOT / d).read_text()) for d in DOCS} + +def markdown_links(text): + for m in re.finditer(r'(?