docs: add researched authoring handbook and portable validation
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user