#!/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())