tools: 固化文档校验器为 GATE-DOCS(此前只存在于 /tmp,保证不可复现)
四个校验器原先散落在 /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/ 改动。
This commit is contained in:
parent
c2f6f32093
commit
a2297c21e8
|
|
@ -13,6 +13,12 @@
|
||||||
> `docs/deployment.md` §2.1、`docs/reference.md` §3/§4、`README` 中英双版;⑥ 落地 `/search` 与两项可靠性修复,
|
> `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` 代码缺口区同步更新。
|
> `docs/design.md` §5.7、`docs/reference.md` §6.3/§6.4、`docs/roadmap.md` 代码缺口区同步更新。
|
||||||
> 各文档的行号锚点以代码 `bfe0fd8` 为准,代码变动后须同步重锚。
|
> 各文档的行号锚点以代码 `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` 副本。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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'(?<!!)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)', text):
|
||||||
|
yield m.group(1)
|
||||||
|
|
||||||
|
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
|
||||||
|
if line.count('`') % 2:
|
||||||
|
errors.append(f'{d}:{i} odd backtick count')
|
||||||
|
for url in markdown_links(line):
|
||||||
|
if url.startswith(('http://', 'https://', 'mailto:')):
|
||||||
|
continue
|
||||||
|
if url.startswith('#'):
|
||||||
|
frag = url[1:]
|
||||||
|
if frag and frag not in HEADS[d]:
|
||||||
|
errors.append(f'{d}:{i} in-page anchor "{frag}" not found')
|
||||||
|
continue
|
||||||
|
if '#' in url:
|
||||||
|
tgt_rel, frag = url.split('#', 1)
|
||||||
|
else:
|
||||||
|
tgt_rel, frag = url, ''
|
||||||
|
tgt_rel = tgt_rel or d
|
||||||
|
base = (ROOT / d).parent
|
||||||
|
try:
|
||||||
|
resolved = Path(base, tgt_rel).resolve()
|
||||||
|
tgt_rel = str(resolved.relative_to(ROOT))
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f'{d}:{i} escapes repo: {url}')
|
||||||
|
continue
|
||||||
|
if not resolved.exists():
|
||||||
|
errors.append(f'{d}:{i} broken link: {url}')
|
||||||
|
continue
|
||||||
|
if frag:
|
||||||
|
hs = HEADS.get(tgt_rel)
|
||||||
|
if hs is None:
|
||||||
|
if resolved.suffix == '.md':
|
||||||
|
hs = heading_set(resolved.read_text())
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if frag not in hs:
|
||||||
|
errors.append(f'{d}:{i} anchor "{frag}" not in {tgt_rel}')
|
||||||
|
if in_fence:
|
||||||
|
errors.append(f'{d}: unclosed fenced code block')
|
||||||
|
|
||||||
|
# cross-document section references (allowed: same-doc "本文件 §", AGENTS.md index table)
|
||||||
|
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 or '`' in line or line.lstrip().startswith(('#', '|')):
|
||||||
|
continue
|
||||||
|
if re.search(r'docs/[a-z]+\.md|README(?:\.zh-CN)?\.md', line) and '§' in line:
|
||||||
|
if d == 'AGENTS.md' or '本文件' in line:
|
||||||
|
continue
|
||||||
|
errors.append(f'{d}:{i} cross-doc section ref: {line.strip()}')
|
||||||
|
|
||||||
|
print(f'\n=== VALIDATION ({len(errors)} errors) ===')
|
||||||
|
for e in errors:
|
||||||
|
print(e)
|
||||||
|
sys.exit(1 if errors else 0)
|
||||||
Loading…
Reference in New Issue