#!/usr/bin/env python3 """Validate README coverage and local links in the tracked docs tree. Only directories containing tracked files are required to have a README, so generated/gitignored folders such as docs/screens/realism are exempt. """ from __future__ import annotations import argparse import re import subprocess import sys from pathlib import Path from urllib.parse import unquote ROOT = Path(__file__).resolve().parent.parent IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") HTML_SRC = re.compile(r"""(?:src|href)=["']([^"']+)["']""") def _git_tracked(root: Path) -> list[Path]: """Return repository-relative tracked paths (fallback: all docs files).""" try: out = subprocess.check_output( ["git", "-C", str(root), "ls-files", "-z", "docs"], stderr=subprocess.DEVNULL, ) except (OSError, subprocess.CalledProcessError): return [p.relative_to(root) for p in (root / "docs").rglob("*") if p.is_file()] return [Path(item.decode()) for item in out.split(b"\0") if item] def _is_ignored(root: Path, target: Path) -> bool: """True for local generated files excluded from the repository (e.g. realism/previews).""" try: rel = target.resolve().relative_to(root.resolve()) except ValueError: return False try: return subprocess.run( ["git", "-C", str(root), "check-ignore", "-q", "--", str(rel)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode == 0 except OSError: return False def _local_target(readme: Path, target: str) -> Path | None: target = unquote(target.split("#", 1)[0].strip()) if not target or "://" in target or target.startswith(("mailto:", "tel:")): return None return (readme.parent / target).resolve() def check_docs(root: Path = ROOT, tracked: list[Path] | None = None) -> list[str]: """Return validation errors; an empty list means the docs tree is valid.""" root = root.resolve() docs = root / "docs" errors: list[str] = [] if not docs.is_dir(): return [f"missing docs directory: {docs}"] files = tracked if tracked is not None else _git_tracked(root) tracked_files = {root / p for p in files if str(p).startswith("docs/") or str(p) == "docs"} tracked_dirs = {p.parent for p in tracked_files if p.parent != docs} tracked_dirs.add(docs) readmes: dict[Path, str] = {} for directory in sorted(tracked_dirs): readme = directory / "README.md" if not readme.is_file(): errors.append(f"missing README.md in tracked docs directory: {directory.relative_to(root)}") continue text = readme.read_text(encoding="utf-8") readmes[directory] = text linked_names = set() for pattern in (MARKDOWN_LINK, HTML_SRC): for match in pattern.finditer(text): target = _local_target(readme, match.group(1)) if target is None: continue linked_names.add(target.name) if not target.exists() and not _is_ignored(root, target): errors.append(f"{readme.relative_to(root)}: broken link {match.group(1)}") # Every tracked image in the folder should be visible from its README. for image in sorted(tracked_files): if image.parent == directory and image.suffix.lower() in IMAGE_EXTS \ and image.name not in linked_names: errors.append(f"{readme.relative_to(root)}: image not linked: {image.name}") return errors def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=ROOT, help="repository root") args = parser.parse_args(argv) errors = check_docs(args.root) if errors: for error in errors: print(f"ERROR: {error}", file=sys.stderr) print(f"check_docs: {len(errors)} error(s)", file=sys.stderr) return 1 print("check_docs: OK") return 0 if __name__ == "__main__": raise SystemExit(main())