#!/usr/bin/env python3 """Enforce docs/STRUCTURE.md. Run from the repo root: python tools/check_requirements.py Exit 0 = the docs structure is consistent. Exit 1 = it is not. Checks are listed in docs/STRUCTURE.md. """ from __future__ import annotations import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DOCS = ROOT / "docs" STRUCTURE = DOCS / "STRUCTURE.md" REQUIREMENTS = DOCS / "REQUIREMENTS.md" MILESTONES = DOCS / "MILESTONES.md" EXEMPT_PREFIXES = (("docs", "archive"), ("docs", "_templates")) ROLES = "index|intent|meta|requirements|scope|capability|contract|practice" REGISTRY_ROW = re.compile(rf"^\|\s*`(docs/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|") NAMESPACE_ROW = re.compile(r"^\|\s*`([A-Z]+)`\s*\|") REQUIREMENT_ROW = re.compile( r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$" ) MILESTONE_IDS = re.compile(r"^-\s*IDs:\s*(.*)$") ID_TOKEN = re.compile(r"^[A-Z]+-\d+$") SCOPES = {"shipped", "M3", "later"} PLACEHOLDER = {"", "—", "-", "*(none yet)*"} def main() -> int: errors: list[str] = [] if not STRUCTURE.exists(): print("FAIL check_requirements: docs/STRUCTURE.md is missing") return 1 structure = STRUCTURE.read_text(encoding="utf-8") registered: dict[str, str] = {} for line in structure.splitlines(): match = REGISTRY_ROW.match(line) if match: registered[match.group(1)] = match.group(2) namespaces = { match.group(1) for line in structure.splitlines() if (match := NAMESPACE_ROW.match(line)) } if not namespaces: errors.append("STRUCTURE.md declares no ID namespaces") # 1. every non-exempt doc is registered for path in sorted(DOCS.rglob("*.md")): rel = path.relative_to(ROOT).as_posix() if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES: continue if rel not in registered: errors.append(f"unregistered doc: {rel}") # 2. every registered doc exists for rel in registered: if not (ROOT / rel).exists(): errors.append(f"registered doc missing on disk: {rel}") # 3-5. requirement rows ids: dict[str, str] = {} if not REQUIREMENTS.exists(): errors.append("docs/REQUIREMENTS.md is missing") else: for number, line in enumerate( REQUIREMENTS.read_text(encoding="utf-8").splitlines(), 1 ): match = REQUIREMENT_ROW.match(line) if not match: continue prefix, digit, _statement, scope, test = match.groups() rid = f"{prefix}-{digit}" loc = f"docs/REQUIREMENTS.md:{number}" if prefix not in namespaces: errors.append(f"{loc} unknown namespace '{prefix}'") if rid in ids: errors.append(f"{loc} duplicate id {rid} (first seen at {ids[rid]})") else: ids[rid] = loc if scope not in SCOPES: errors.append(f"{loc} {rid} invalid scope '{scope}'") if scope in {"shipped", "M3"}: if test.strip() in PLACEHOLDER: errors.append(f"{loc} {rid} scope {scope} needs a Test path") elif not (ROOT / test.strip()).exists(): errors.append(f"{loc} {rid} test path not found: {test.strip()}") # 6. requirement rows appear only in REQUIREMENTS.md for path in sorted(DOCS.rglob("*.md")): if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES or path in { REQUIREMENTS, STRUCTURE, }: continue rel = path.relative_to(ROOT).as_posix() for number, line in enumerate( path.read_text(encoding="utf-8").splitlines(), 1 ): if REQUIREMENT_ROW.match(line): errors.append(f"{rel}:{number} requirement row outside REQUIREMENTS.md") # 7. milestone IDs resolve if not MILESTONES.exists(): errors.append("docs/MILESTONES.md is missing") else: for number, line in enumerate( MILESTONES.read_text(encoding="utf-8").splitlines(), 1 ): match = MILESTONE_IDS.match(line) if not match: continue for token in match.group(1).split(","): token = token.strip().strip("`").strip() if token in PLACEHOLDER: continue if not ID_TOKEN.match(token): errors.append(f"MILESTONES.md:{number} not an ID: '{token}'") elif token not in ids: errors.append(f"MILESTONES.md:{number} unknown ID {token}") if errors: print(f"FAIL check_requirements: {len(errors)} problem(s)") for error in errors: print(f" - {error}") return 1 print( f"PASS check_requirements: {len(registered)} docs, " f"{len(namespaces)} namespaces, {len(ids)} requirements" ) return 0 if __name__ == "__main__": sys.exit(main())