141 lines
4.8 KiB
Python
Executable File
141 lines
4.8 KiB
Python
Executable File
#!/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"<!--\n"
|
|
r"Doc-ID: (?P<doc_id>[^\n]+)\n"
|
|
r"Version: (?P<version>[^\n]+)\n"
|
|
r"Status: (?P<status>[^\n]+)\n"
|
|
r"Owner: (?P<owner>[^\n]+)\n"
|
|
r"Updated: (?P<updated>\d{4}-\d{2}-\d{2})\n"
|
|
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<id>(?:REQ|RULE|TASK|TEST|DOC)-[A-Z0-9-]+-\d{3})`", re.MULTILINE)
|
|
LINK_PATTERN = re.compile(r"\[[^\]]+\]\((?P<link>[^)]+)\)")
|
|
LINE_DEF_PATTERN = re.compile(r"^-\s+`(?P<id>(?: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())
|