Files
SlabUploader/tools/check_requirements.py
T
Ty 6435199f08 docs(structure): govern .planning/ and register the ops runbook
- docs/STRUCTURE.md: add a .planning/ registry (vision/roadmap/history/breakdown
  roles), broaden the roadmap role, add enforcement items 8-10.
- tools/check_requirements.py: scan .planning/ too - every .planning/**.md must be
  registered, registered files must exist, requirement rows rejected there.
- .planning/: PROJECT.md, ROADMAP.md, MILESTONES.md, and milestones/v0.5/M3/
  {roadmap.md,task_breakdown.md}. M3 breakdown holds only the tasks; decisions live
  in the contracts.
- docs/DEPLOYMENT.md: add the ops runbook (registered as ops).
2026-09-19 10:12:32 -07:00

174 lines
6.0 KiB
Python

#!/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+$")
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}")
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())