165 lines
7.1 KiB
Python
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())
|