#!/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" PLANNING = ROOT / ".planning" 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|ops" "|vision|roadmap|history|breakdown" ) REGISTRY_ROW = re.compile( rf"^\|\s*`((?:docs|\.planning)/[^`]+\.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+$") # An ID cited in prose, e.g. `METADATA-55` or (REVIEW-15). CITED_ID = re.compile(r"\b([A-Z]+-\d+)\b") # Behavior-claim language; a section using it must cite an ID. NORMATIVE = re.compile(r"\b(must|always|never|only runs|requires|is required)\b", re.IGNORECASE) # Sections that document external facts or shapes, not our behavior. EXTERNAL_SECTION = re.compile( r"(shape|versioning|error|canonical|attributes` element)", re.IGNORECASE ) # Docs that cite requirement IDs and must carry a "Requirements impact" table. CITING_ROLES = {"capability", "contract"} # Docs whose whole job is citing IDs across the set; no subset table applies. CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"} HEADING = re.compile(r"^(#{2,4})\s+(.*)$") 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") planning_files = sorted(PLANNING.rglob("*.md")) if PLANNING.is_dir() else [] # 1. every non-exempt doc is registered; every planning file 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}") for path in planning_files: rel = path.relative_to(ROOT).as_posix() if rel not in registered: errors.append(f"unregistered planning file: {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] = {} test_refs: list[tuple[str, str, 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}'") test_ref = test.strip() if test_ref not in PLACEHOLDER: test_refs.append((loc, rid, test_ref, scope)) # 5. resolve test references: an existing path, a requirement ID, or — for loc, rid, ref, scope in test_refs: if ID_TOKEN.match(ref): if ref not in ids: errors.append(f"{loc} {rid} test ref {ref} is not a defined requirement") elif "/" in ref or "\\" in ref or "." in ref: if scope == "shipped" and not (ROOT / ref).exists(): errors.append(f"{loc} {rid} shipped test path not found: {ref}") else: errors.append(f"{loc} {rid} invalid Test ref '{ref}' (use a path, an ID, or —)") # 6. requirement rows appear only in REQUIREMENTS.md for path in sorted(DOCS.rglob("*.md")) + planning_files: 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}") # 11. a citing doc may reference only IDs it declares in its Requirements impact table for rel, role in sorted(registered.items()): if role not in CITING_ROLES or rel in CITATION_EXEMPT: continue path = ROOT / rel if not path.exists(): continue text = path.read_text(encoding="utf-8") declared: set[str] = set() section = "" for line in text.splitlines(): if line.startswith("#"): section = line continue if "requirements impact" in section.lower(): declared.update(CITED_ID.findall(line)) if not declared: errors.append(f"{rel} is role '{role}' but declares no Requirements impact IDs") continue for number, line in enumerate(text.splitlines(), 1): for cited in CITED_ID.findall(line): if cited in ids and cited not in declared: errors.append( f"{rel}:{number} cites {cited} outside its Requirements impact table" ) # 12. a normative section in a citing doc must cite an ID for rel, role in sorted(registered.items()): if role not in CITING_ROLES or rel in CITATION_EXEMPT: continue path = ROOT / rel if not path.exists(): continue section = "" cites: set[str] = set() line_no = 0 blocks: list[tuple[str, int, set[str]]] = [] def flush() -> None: if section and NORMATIVE.search(block_text) and not cites: blocks.append((section, line_no, set())) block_text = "" for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): if line.startswith("#"): flush() section = line cites = set() block_text = "" line_no = number continue block_text += line + "\n" cites.update(CITED_ID.findall(line)) flush() for name, at, _ in blocks: if EXTERNAL_SECTION.search(name): continue errors.append( f"{rel}:{at} section '{name.strip('# ')}' makes a behavior claim but cites no requirement ID" ) if errors: print(f"FAIL check_requirements: {len(errors)} problem(s)") for error in errors: print(f" - {error}") return 1 planning_registered = sum(1 for rel in registered if rel.startswith(".planning/")) print( f"PASS check_requirements: {len(registered) - planning_registered} docs, " f"{planning_registered} planning, " f"{len(namespaces)} namespaces, {len(ids)} requirements" ) return 0 if __name__ == "__main__": sys.exit(main())