import pathlib
import re
import urllib.parse
root = pathlib.Path(__file__).resolve().parent.parent
manual_dir = root / "docs" / "manual"
documents = [root / "README.md", root / "SECURITY.md", *sorted(manual_dir.glob("*.md"))]
link_pattern = re.compile(r"(?<!!)\[[^]]*\]\(([^)]+)\)")
failures = []
stale_phrases = {
"docs/README.md": "stale flat manual path",
"05-planning-and-concurrency": "stale combined planning/concurrency chapter",
"1.0 发布候选配置": "stale pre-release configuration wording",
"1.0 正处于发布候选阶段": "stale pre-release status wording",
}
def anchors_for(text):
anchors = set(re.findall(r'<a\s+id=["\']([^"\']+)["\']\s*></a>', text, re.IGNORECASE))
counts = {}
for heading in re.findall(r"^#{1,6}\s+(.+?)\s*$", text, re.MULTILINE):
heading = re.sub(r"<[^>]+>", "", heading).strip().lower()
slug = "".join(
character
for character in heading
if character.isalnum() or character in " _-"
).replace(" ", "-")
count = counts.get(slug, 0)
counts[slug] = count + 1
anchors.add(slug if count == 0 else f"{slug}-{count}")
return anchors
def prose_lines(text):
fenced = False
for number, line in enumerate(text.splitlines(), 1):
if line.lstrip().startswith("```"):
fenced = not fenced
continue
if not fenced:
yield number, line
for document in documents:
text = document.read_text(encoding="utf-8")
for phrase, message in stale_phrases.items():
if phrase in text:
failures.append(f"{document.relative_to(root)}: {message}: {phrase}")
for raw_target in link_pattern.findall(text):
target = raw_target.strip().split(maxsplit=1)[0].strip("<>")
if not target or target.startswith(("http://", "https://", "mailto:")):
continue
path_text, _, fragment = target.partition("#")
resolved = document if not path_text else (document.parent / urllib.parse.unquote(path_text)).resolve()
try:
resolved.relative_to(root)
except ValueError:
failures.append(f"{document.relative_to(root)}: link escapes repository: {target}")
continue
if not resolved.exists():
failures.append(f"{document.relative_to(root)}: missing link target: {target}")
continue
if fragment and resolved.suffix == ".md":
destination = resolved.read_text(encoding="utf-8")
decoded_fragment = urllib.parse.unquote(fragment).lower()
if decoded_fragment not in anchors_for(destination):
failures.append(f"{document.relative_to(root)}: missing link anchor: {target}")
if failures:
raise SystemExit("\n".join(failures))
expected_docs = {"README.md", "00-preface.md", *(f"{index:02d}-{name}.md" for index, name in [
(1, "overview"),
(2, "getting-started"),
(3, "commands"),
(4, "configuration"),
(5, "planning-and-dag"),
(6, "efficiency-and-concurrency"),
(7, "artifacts-and-recovery"),
(8, "output-and-interaction"),
(9, "security"),
(10, "architecture"),
(11, "quality-and-release"),
(12, "engineering-reference"),
])}
actual_docs = {path.name for path in manual_dir.iterdir() if path.is_file()}
expected_directories = {"manual", "web"}
actual_directories = {path.name for path in (root / "docs").iterdir() if path.is_dir()}
unexpected_files = sorted(path.name for path in (root / "docs").iterdir() if path.is_file())
if actual_docs != expected_docs or actual_directories != expected_directories or unexpected_files:
raise SystemExit(
"docs layout differs: "
f"expected manual files {sorted(expected_docs)} and directories {sorted(expected_directories)}, "
f"got manual files {sorted(actual_docs)}, directories {sorted(actual_directories)}, "
f"root files {unexpected_files}"
)
for document in sorted(manual_dir.glob("[0-9][0-9]-*.md")):
chapter = int(document.name[:2])
text = document.read_text(encoding="utf-8")
for section in re.findall(r"^##\s+(\d+)\.", text, re.MULTILINE):
if int(section) != chapter:
failures.append(
f"{document.relative_to(root)}: section {section} does not match chapter {chapter}"
)
for document in sorted(manual_dir.glob("*.md")):
text = document.read_text(encoding="utf-8")
relative = document.relative_to(root)
if text.count("```") % 2:
failures.append(f"{relative}: unbalanced fenced code block")
headings = []
for number, line in prose_lines(text):
match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line)
if match:
headings.append((number, len(match.group(1)), match.group(2)))
if len(line) > 180 and not line.startswith("|"):
failures.append(
f"{relative}:{number}: prose source line exceeds 180 characters"
)
top_level = [heading for heading in headings if heading[1] == 1]
if len(top_level) != 1:
failures.append(f"{relative}: expected exactly one H1, got {len(top_level)}")
for previous, current in zip(headings, headings[1:]):
if current[1] > previous[1] + 1:
failures.append(
f"{relative}:{current[0]}: heading level jumps from H{previous[1]} "
f"to H{current[1]}"
)
for document in sorted(manual_dir.glob("*.md")):
text = document.read_text(encoding="utf-8")
if not re.search(r"[\u3400-\u4dbf\u4e00-\u9fff]", text):
failures.append(f"{document.relative_to(root)}: manual document must contain Chinese prose")
if failures:
raise SystemExit("\n".join(failures))
print("Documentation links and docs/manual + docs/web layout are consistent.")