#!/usr/bin/env python3 """Validate Ouroboros planning docs for metadata, links, and ID consistency.""" from __future__ import annotations import re import sys from pathlib import Path DOC_DIR = Path("docs/ouroboros") META_PATTERN = re.compile( r"", re.MULTILINE, ) ID_PATTERN = re.compile(r"\b(?:REQ|RULE|TASK|TEST|DOC)-[A-Z0-9-]+-\d{3}\b") DEF_PATTERN = re.compile(r"^-\s+`(?P(?:REQ|RULE|TASK|TEST|DOC)-[A-Z0-9-]+-\d{3})`", re.MULTILINE) LINK_PATTERN = re.compile(r"\[[^\]]+\]\((?P[^)]+)\)") LINE_DEF_PATTERN = re.compile(r"^-\s+`(?P(?:REQ|RULE|TASK|TEST|DOC)-[A-Z0-9-]+-\d{3})`.*$", re.MULTILINE) def iter_docs() -> list[Path]: return sorted([p for p in DOC_DIR.glob("*.md") if p.is_file()]) def validate_metadata(path: Path, text: str, errors: list[str], doc_ids: dict[str, Path]) -> None: match = META_PATTERN.search(text) if not match: errors.append(f"{path}: missing or malformed metadata block") return doc_id = match.group("doc_id").strip() if doc_id in doc_ids: errors.append(f"{path}: duplicate Doc-ID {doc_id} (already in {doc_ids[doc_id]})") else: doc_ids[doc_id] = path def validate_links(path: Path, text: str, errors: list[str]) -> None: for m in LINK_PATTERN.finditer(text): link = m.group("link").strip() if not link or link.startswith("http") or link.startswith("#"): continue if link.startswith("/"): target = Path(link) else: target = (path.parent / link).resolve() if not target.exists(): errors.append(f"{path}: broken link -> {link}") def collect_ids(path: Path, text: str, defs: dict[str, Path], refs: dict[str, set[Path]]) -> None: for m in DEF_PATTERN.finditer(text): defs[m.group("id")] = path for m in ID_PATTERN.finditer(text): idv = m.group(0) refs.setdefault(idv, set()).add(path) def collect_req_traceability(text: str, req_to_task: dict[str, set[str]], req_to_test: dict[str, set[str]]) -> None: for m in LINE_DEF_PATTERN.finditer(text): line = m.group(0) item_id = m.group("id") req_ids = [rid for rid in ID_PATTERN.findall(line) if rid.startswith("REQ-")] if item_id.startswith("TASK-"): for req_id in req_ids: req_to_task.setdefault(req_id, set()).add(item_id) if item_id.startswith("TEST-"): for req_id in req_ids: req_to_test.setdefault(req_id, set()).add(item_id) def main() -> int: if not DOC_DIR.exists(): print(f"ERROR: missing directory {DOC_DIR}") return 1 docs = iter_docs() if not docs: print(f"ERROR: no markdown docs found in {DOC_DIR}") return 1 errors: list[str] = [] doc_ids: dict[str, Path] = {} defs: dict[str, Path] = {} refs: dict[str, set[Path]] = {} req_to_task: dict[str, set[str]] = {} req_to_test: dict[str, set[str]] = {} for path in docs: text = path.read_text(encoding="utf-8") validate_metadata(path, text, errors, doc_ids) validate_links(path, text, errors) collect_ids(path, text, defs, refs) collect_req_traceability(text, req_to_task, req_to_test) for idv, where_used in sorted(refs.items()): if idv.startswith("DOC-"): continue if idv not in defs: files = ", ".join(str(p) for p in sorted(where_used)) errors.append(f"undefined ID {idv}, used in: {files}") for idv in sorted(defs): if not idv.startswith("REQ-"): continue if idv not in req_to_task: errors.append(f"REQ without TASK mapping: {idv}") if idv not in req_to_test: errors.append(f"REQ without TEST mapping: {idv}") warnings: list[str] = [] for idv, where_def in sorted(defs.items()): if len(refs.get(idv, set())) <= 1 and (idv.startswith("REQ-") or idv.startswith("RULE-")): warnings.append(f"orphan ID {idv} defined in {where_def} (not referenced elsewhere)") if errors: print("[FAIL] Ouroboros docs validation failed") for err in errors: print(f"- {err}") return 1 print(f"[OK] validated {len(docs)} docs in {DOC_DIR}") print(f"[OK] unique Doc-ID: {len(doc_ids)}") print(f"[OK] definitions: {len(defs)}, references: {len(refs)}") print(f"[OK] req->task mappings: {len(req_to_task)}") print(f"[OK] req->test mappings: {len(req_to_test)}") if warnings: print(f"[WARN] orphan IDs: {len(warnings)}") for w in warnings: print(f"- {w}") return 0 if __name__ == "__main__": sys.exit(main())