docs(structure): add requirements contract, job namespaces, and enforcement gate
Phase 1 scaffold for the docs consolidation: - docs/STRUCTURE.md: artifact registry, 8 owner-job ID namespaces, routing and change rules - docs/REQUIREMENTS.md, docs/MILESTONES.md: normative schema, empty pending Phase 2 port - docs/_templates/: requirement-row, capability, contract, adr - docs/capabilities/, docs/contracts/: registered homes - tools/check_requirements.py: enforces registry, IDs, and traceability - AGENTS.md: coding paused banner + structure-contract routing rule - README.md: pointers to the structure/requirements/milestones set Additive only: no doc moves, no requirement text, no CI wiring yet.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# tools
|
||||
|
||||
Structure and verification gates. These are the only sanctioned way to claim the docs are clean or a slice is "done".
|
||||
|
||||
| Script | Purpose | Run |
|
||||
|---|---|---|
|
||||
| `check_requirements.py` | Enforce `docs/STRUCTURE.md`: registered docs, requirement IDs, traceability. | `python tools/check_requirements.py` |
|
||||
|
||||
Run from the repo root. Exit 0 = pass, exit 1 = fail.
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user