249 lines
9.0 KiB
Python
249 lines
9.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+$")
|
|
# 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", "M4", "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())
|