Pulse/scripts/check_public_docs.py
2026-08-06 01:00:41 +01:00

168 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Validate the repository's public documentation surface."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import unquote
ROOT = Path(__file__).resolve().parents[1]
ROOT_DOCS = (
"README.md",
"CONTRIBUTING.md",
"CHANGELOG.md",
"ARCHITECTURE.md",
"SECURITY.md",
"TERMS.md",
)
CURRENT_PRODUCT_DOCS = (
"README.md",
"ARCHITECTURE.md",
"docs/README.md",
"docs/FAQ.md",
"docs/SCREENSHOTS.md",
)
MARKDOWN_LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
HTML_ASSET = re.compile(r"(?:src|href)=[\"']([^\"']+)[\"']")
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$", re.MULTILINE)
def public_markdown_files() -> list[Path]:
files = [ROOT / name for name in ROOT_DOCS if (ROOT / name).exists()]
files.extend(sorted((ROOT / "docs").glob("*.md")))
files.extend(sorted((ROOT / "docs" / "i18n").glob("**/*.md")))
files.extend(sorted((ROOT / ".github" / "ISSUE_TEMPLATE").glob("*.md")))
return files
def split_target(raw_target: str) -> tuple[str, str]:
target = raw_target.strip().strip("<>")
if " " in target and not target.startswith("#"):
target = target.split(" ", 1)[0]
path, separator, anchor = target.partition("#")
return unquote(path), unquote(anchor) if separator else ""
def heading_slug(value: str) -> str:
value = re.sub(r"<[^>]+>", "", value)
value = re.sub(r"[`*_~]", "", value).lower()
value = re.sub(r"[^\w\s-]", "", value, flags=re.UNICODE)
return re.sub(r"\s", "-", value).strip("-")
def anchors_for(path: Path) -> set[str]:
text = path.read_text(encoding="utf-8", errors="replace")
anchors: set[str] = set()
counts: dict[str, int] = {}
for heading in HEADING.findall(text):
base = heading_slug(heading)
if not base:
continue
count = counts.get(base, 0)
anchors.add(base if count == 0 else f"{base}-{count}")
counts[base] = count + 1
return anchors
def check_links(files: list[Path]) -> list[str]:
errors: list[str] = []
anchor_cache: dict[Path, set[str]] = {}
for source in files:
text = source.read_text(encoding="utf-8", errors="replace")
targets = [match.group(1) for match in MARKDOWN_LINK.finditer(text)]
targets.extend(match.group(1) for match in HTML_ASSET.finditer(text))
for raw_target in targets:
if raw_target.startswith(("http://", "https://", "mailto:", "data:")):
continue
target_path, anchor = split_target(raw_target)
resolved = source if not target_path else (source.parent / target_path).resolve()
display_source = source.relative_to(ROOT)
if not resolved.exists():
errors.append(f"{display_source}: missing local target {raw_target}")
continue
# GitHub retains some emoji variation selectors in generated
# anchors. Those anchors are stable in the rendered document but
# are not practical to reproduce with the standard library, so
# validate ordinary text anchors and still verify the target file
# for emoji-prefixed anchors.
if (
anchor
and resolved.suffix.lower() == ".md"
and not anchor.startswith(("-", "\ufe0f"))
):
anchors = anchor_cache.setdefault(resolved, anchors_for(resolved))
if anchor.lower() not in anchors:
errors.append(
f"{display_source}: missing anchor #{anchor} in "
f"{resolved.relative_to(ROOT)}"
)
return errors
def check_current_claims() -> list[str]:
errors: list[str] = []
retired_patterns = {
r"organis(?:e|z)(?:s|ed)\s+the\s+ui\s+by\s+task":
"retired task-based navigation claim",
r"unified,\s*task-based navigation":
"retired unified-navigation screenshot claim",
r"canonical v6 task surfaces":
"retired canonical task-surface claim",
}
for relative_path in CURRENT_PRODUCT_DOCS:
path = ROOT / relative_path
text = path.read_text(encoding="utf-8", errors="replace")
for pattern, label in retired_patterns.items():
if re.search(pattern, text, flags=re.IGNORECASE):
errors.append(f"{relative_path}: {label}")
readme_lines = (ROOT / "README.md").read_text(encoding="utf-8").splitlines()
if len(readme_lines) > 220:
errors.append(
f"README.md: {len(readme_lines)} lines exceeds the 220-line landing-page budget"
)
issue_config = (ROOT / ".github" / "ISSUE_TEMPLATE" / "config.yml").read_text(
encoding="utf-8"
)
if "github.com/rcourtman/Pulse/wiki" in issue_config:
errors.append(".github/ISSUE_TEMPLATE/config.yml: documentation link points at the retired wiki")
artifact_hub = ROOT / "artifacthub-repo.yml"
if artifact_hub.exists() and "01234567-89ab-cdef-0123-456789abcdef" in artifact_hub.read_text(
encoding="utf-8"
):
errors.append("artifacthub-repo.yml: placeholder repository ID is not publishable metadata")
return errors
def main() -> int:
files = public_markdown_files()
errors = check_links(files)
errors.extend(check_current_claims())
if errors:
print("Public documentation check failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print(f"Public documentation check passed ({len(files)} Markdown files).")
return 0
if __name__ == "__main__":
raise SystemExit(main())