diff --git a/scripts/validate_ouroboros_docs.py b/scripts/validate_ouroboros_docs.py index 2cbeb7f..9f1485e 100755 --- a/scripts/validate_ouroboros_docs.py +++ b/scripts/validate_ouroboros_docs.py @@ -19,9 +19,20 @@ META_PATTERN = re.compile( 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) +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) +LINE_DEF_PATTERN = re.compile( + r"^-\s+`(?P(?:REQ|RULE|TASK|TEST|DOC)-[A-Z0-9-]+-\d{3})`.*$", + re.MULTILINE, +) +PLAN_LINK_PATTERN = re.compile(r"ouroboros_plan_v(?P[23])\.txt$") +ALLOWED_PLAN_TARGETS = { + "2": (DOC_DIR / "source" / "ouroboros_plan_v2.txt").resolve(), + "3": (DOC_DIR / "source" / "ouroboros_plan_v3.txt").resolve(), +} def iter_docs() -> list[Path]: @@ -40,11 +51,35 @@ def validate_metadata(path: Path, text: str, errors: list[str], doc_ids: dict[st doc_ids[doc_id] = path +def validate_plan_source_link(path: Path, link: str, errors: list[str]) -> None: + normalized = link.strip() + match = PLAN_LINK_PATTERN.search(normalized) + if not match: + return + + version = match.group("version") + expected_target = ALLOWED_PLAN_TARGETS[version] + if normalized.startswith("/"): + errors.append( + f"{path}: invalid plan link path -> {link} " + f"(use ./source/ouroboros_plan_v{version}.txt)" + ) + return + + resolved_target = (path.parent / normalized).resolve() + if resolved_target != expected_target: + errors.append( + f"{path}: invalid plan link path -> {link} " + f"(must resolve to docs/ouroboros/source/ouroboros_plan_v{version}.txt)" + ) + + 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 + validate_plan_source_link(path, link, errors) if link.startswith("/"): target = Path(link) else: @@ -61,7 +96,9 @@ def collect_ids(path: Path, text: str, defs: dict[str, Path], refs: dict[str, se 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: +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") diff --git a/tests/test_validate_ouroboros_docs.py b/tests/test_validate_ouroboros_docs.py new file mode 100644 index 0000000..66c0ec5 --- /dev/null +++ b/tests/test_validate_ouroboros_docs.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +def _load_module(): + script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_ouroboros_docs.py" + spec = importlib.util.spec_from_file_location("validate_ouroboros_docs", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_validate_plan_source_link_accepts_canonical_source_path() -> None: + module = _load_module() + errors: list[str] = [] + path = Path("docs/ouroboros/README.md").resolve() + + module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt", errors) + module.validate_plan_source_link(path, "./source/ouroboros_plan_v3.txt", errors) + + assert errors == [] + + +def test_validate_plan_source_link_rejects_root_relative_path() -> None: + module = _load_module() + errors: list[str] = [] + path = Path("docs/ouroboros/README.md").resolve() + + module.validate_plan_source_link( + path, + "/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt", + errors, + ) + + assert errors + assert "invalid plan link path" in errors[0] + assert "use ./source/ouroboros_plan_v2.txt" in errors[0] + + +def test_validate_plan_source_link_rejects_repo_root_relative_path() -> None: + module = _load_module() + errors: list[str] = [] + path = Path("docs/ouroboros/README.md").resolve() + + module.validate_plan_source_link(path, "../../ouroboros_plan_v2.txt", errors) + + assert errors + assert "invalid plan link path" in errors[0] + assert "must resolve to docs/ouroboros/source/ouroboros_plan_v2.txt" in errors[0]