Files
Endorphin-Document/tools/check_docs.py
T

165 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Read-only structural checks for the documentation repository (Python 3.10+)."""
from __future__ import annotations
import argparse
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path, PureWindowsPath
import re
from urllib.parse import unquote, urlsplit
@dataclass
class Report:
files: int = 0
links: int = 0
blocks: int = 0
errors: list[str] = field(default_factory=list)
def heading_slug(title: str) -> str:
title = re.sub(r"<[^>]+>", "", title).strip().lower()
title = re.sub(r"[^\w\s-]", "", title)
return re.sub(r"\s", "-", title)
def extract(body: str, label: str, report: Report):
"""Return non-fenced lines and (language, start line, content) code blocks."""
visible = []
blocks = []
marker = None
language = ""
start = 0
content = []
for number, line in enumerate(body.splitlines(), 1):
match = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line)
if marker is None:
if match:
marker = match[1]
language = match[2].strip().split(maxsplit=1)[0] if match[2].strip() else ""
start = number
content = []
else:
visible.append((number, line))
elif match and match[1][0] == marker[0] and len(match[1]) >= len(marker) and not match[2].strip():
blocks.append((language, start, "\n".join(content)))
marker = None
else:
content.append(line)
if marker is not None:
report.errors.append(f"{label}:{start}: unclosed code fence")
report.blocks += len(blocks)
return visible, blocks
def heading_ids(lines):
counts = Counter()
result = set()
for _, line in lines:
match = re.match(r"^ {0,3}#{1,6}\s+(.+?)\s*#*\s*$", line)
if not match:
continue
base = heading_slug(match[1])
count = counts[base]
counts[base] += 1
result.add(base if count == 0 else f"{base}-{count}")
return result
def check_repository(root: Path) -> Report:
root = root.resolve()
report = Report()
if not (root / "README.md").is_file():
report.errors.append("README.md: missing repository entry")
pages = sorted(p for p in root.rglob("*.md") if not any(part.startswith(".") for part in p.relative_to(root).parts))
parsed = {}
for page in pages:
label = page.relative_to(root).as_posix()
try:
body = page.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
report.errors.append(f"{label}: cannot read UTF-8 text: {error}")
continue
report.files += 1
parsed[page] = extract(body, label, report)
if page.parent == root / "authoring" / "rules" or page.parent == root / "authoring" / "research":
id_match = re.fullmatch(r"(DOC-\d{3}|SRC-\d{2})\.md", page.name)
if id_match and not body.startswith(f"# {id_match[1]} "):
report.errors.append(f"{label}: filename and first heading ID differ")
anchors = {page: heading_ids(value[0]) for page, value in parsed.items()}
link_pattern = re.compile(r"!?\[[^\]\n]*\]\(([^)\n]+)\)")
for page, (lines, blocks) in parsed.items():
label = page.relative_to(root).as_posix()
for number, line in lines:
line = re.sub(r"`+[^`]*`+", "", line)
for match in link_pattern.finditer(line):
target = match[1].strip()
# Simple inline Markdown links; optional quoted link titles are allowed.
target = re.sub(r'\s+["\x27].*["\x27]$', "", target).strip("<>")
if target.startswith("//"):
continue # Protocol-relative web URL, not a local filesystem link.
if PureWindowsPath(unquote(target)).drive or target.lower().startswith("file:"):
report.errors.append(f"{label}:{number}: machine-specific absolute link: {target}")
continue
parts = urlsplit(target)
if parts.scheme or parts.netloc:
continue
report.links += 1
path = unquote(parts.path)
fragment = unquote(parts.fragment)
if path.startswith("/") or PureWindowsPath(path).drive:
report.errors.append(f"{label}:{number}: machine-specific absolute link: {target}")
continue
destination = (page.parent / path).resolve() if path else page
if not destination.is_relative_to(root):
report.errors.append(f"{label}:{number}: link escapes repository: {target}")
elif not destination.is_file():
report.errors.append(f"{label}:{number}: missing link target: {target}")
elif fragment and destination.suffix == ".md" and fragment not in anchors.get(destination, set()):
report.errors.append(f"{label}:{number}: missing heading anchor: {target}")
if page.parent == root / "guide":
for language, number, code in blocks:
if language != "endorphin":
continue
if re.search(r"^\s*namespace\b", code, re.M):
report.errors.append(f"{label}:{number}: source declares its own namespace")
for offset, line in enumerate(code.splitlines(), 1):
if re.match(r"^\s*use\b", line) and not re.fullmatch(
r"\s*use\s+[^\s:{}]+:[^\s:.{}]+(?:\.[^\s:.{}]+)*\s*", line
):
report.errors.append(f"{label}:{number + offset}: use must reference one complete namespace key")
chapters = sorted((root / "guide").glob("[0-9][0-9]-*.md"))
numbers = [int(page.name[:2]) for page in chapters]
if numbers and numbers != list(range(1, len(chapters) + 1)):
report.errors.append("guide/: chapter numbering must be unique and contiguous from 01")
entry = "\n".join(line for _, line in parsed.get(root / "README.md", ([], []))[0])
for index, page in enumerate(chapters):
body = "\n".join(line for _, line in parsed.get(page, ([], []))[0])
if f"(guide/{page.name})" not in entry:
report.errors.append(f"{page.name}: missing chapter in README")
for adjacent in chapters[max(0, index - 1):index] + chapters[index + 1:index + 2]:
if f"({adjacent.name})" not in body:
report.errors.append(f"{page.name}: missing navigation to {adjacent.name}")
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
args = parser.parse_args()
report = check_repository(args.root)
print(f"Checked {report.files} Markdown files, {report.links} local links, {report.blocks} fenced blocks.")
for error in report.errors:
print(f"ERROR: {error}")
if not report.errors:
print("PASS: structural checks only; no language compilation, execution, or usability testing.")
return int(bool(report.errors))
if __name__ == "__main__":
raise SystemExit(main())