MessageWeave/scripts/docs_check/check_sec_refs.py

122 lines
5.2 KiB
Python
Executable File

#!/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/design.zh-CN.md",
"docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.md",
"docs/retired.zh-CN.md", "docs/opengaps.md", "docs/opengaps.zh-CN.md",
"docs/charter.md", "docs/charter.zh-CN.md",
"cloudflare-worker/README.md",
"cloudflare-worker/README.zh-CN.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(?:\.zh-CN)?\.md|"
r"reference(?:\.zh-CN)?\.md|retired(?:\.zh-CN)?\.md|opengaps(?:\.zh-CN)?\.md|charter(?:\.zh-CN)?\.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}
# `README.md` exists both at the repo root and under `cloudflare-worker/`, so a
# basename-keyed map silently drops one of them. Keep every file's own heading
# set and let the first occurrence win on a basename collision.
own_heads = {p: headings(os.path.join(ROOT, p)) for p in DOCS}
heads = {}
for p in DOCS:
heads.setdefault(os.path.basename(p), own_heads[p])
errors = []
checked = 0
for rel in DOCS:
base = os.path.basename(rel)
# A basename held by more than one file (`README.md` lives both at the
# repo root and under `cloudflare-worker/`) must resolve against the
# document being scanned when it names that document itself.
heading_set = (lambda t: own_heads[rel] if t == base
else heads.get(t, set()))
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 heading_set(target):
errors.append(
f"{rel}:{lineno}: §{m.group(2)} does not exist "
f"in {target}")
for m in SEC.finditer(line):
# External references are not doc references. Two shapes:
# RFC-style three-part numbering (`§7.2.3`), caught below
# by the trailing-dot test, and an explicit RFC attribute
# on a two-part ref (`RFC 8620 §2.1`).
prefix = line[:m.start()]
if re.search(r"RFC\s+\d{3,5}\s*$", prefix):
continue
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`
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 heading_set(target):
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())