From 117657d13f23718441f3f401d821695142f8064f Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 21:11:34 +0900 Subject: [PATCH 1/2] docs: enforce source path policy for ouroboros plan links (#357) --- scripts/validate_ouroboros_docs.py | 43 ++++++++++++++++++++-- tests/test_validate_ouroboros_docs.py | 53 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/test_validate_ouroboros_docs.py 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] -- 2.49.1 From d1ef79f3855638eb7a547965626be181a0124f72 Mon Sep 17 00:00:00 2001 From: agentson Date: Sun, 1 Mar 2026 21:20:06 +0900 Subject: [PATCH 2/2] docs validator: handle plan link fragments and avoid duplicate link errors --- scripts/validate_ouroboros_docs.py | 28 +++++++++++++-------- tests/test_validate_ouroboros_docs.py | 36 ++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/scripts/validate_ouroboros_docs.py b/scripts/validate_ouroboros_docs.py index 9f1485e..902aded 100755 --- a/scripts/validate_ouroboros_docs.py +++ b/scripts/validate_ouroboros_docs.py @@ -51,27 +51,33 @@ 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: +def validate_plan_source_link(path: Path, link: str, errors: list[str]) -> bool: normalized = link.strip() - match = PLAN_LINK_PATTERN.search(normalized) + # Ignore in-page anchors and parse the filesystem part for validation. + link_path = normalized.split("#", 1)[0].strip() + if not link_path: + return False + match = PLAN_LINK_PATTERN.search(link_path) if not match: - return + return False version = match.group("version") expected_target = ALLOWED_PLAN_TARGETS[version] - if normalized.startswith("/"): + if link_path.startswith("/"): errors.append( f"{path}: invalid plan link path -> {link} " f"(use ./source/ouroboros_plan_v{version}.txt)" ) - return + return True - resolved_target = (path.parent / normalized).resolve() + resolved_target = (path.parent / link_path).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)" ) + return True + return False def validate_links(path: Path, text: str, errors: list[str]) -> None: @@ -79,11 +85,13 @@ def validate_links(path: Path, text: str, errors: list[str]) -> None: 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) + if validate_plan_source_link(path, link, errors): + continue + link_path = link.split("#", 1)[0].strip() + if link_path.startswith("/"): + target = Path(link_path) else: - target = (path.parent / link).resolve() + target = (path.parent / link_path).resolve() if not target.exists(): errors.append(f"{path}: broken link -> {link}") diff --git a/tests/test_validate_ouroboros_docs.py b/tests/test_validate_ouroboros_docs.py index 66c0ec5..3ef972b 100644 --- a/tests/test_validate_ouroboros_docs.py +++ b/tests/test_validate_ouroboros_docs.py @@ -19,8 +19,8 @@ def test_validate_plan_source_link_accepts_canonical_source_path() -> None: 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 module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt", errors) is False + assert module.validate_plan_source_link(path, "./source/ouroboros_plan_v3.txt", errors) is False assert errors == [] @@ -30,12 +30,13 @@ def test_validate_plan_source_link_rejects_root_relative_path() -> None: errors: list[str] = [] path = Path("docs/ouroboros/README.md").resolve() - module.validate_plan_source_link( + handled = module.validate_plan_source_link( path, "/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt", errors, ) + assert handled is True assert errors assert "invalid plan link path" in errors[0] assert "use ./source/ouroboros_plan_v2.txt" in errors[0] @@ -46,8 +47,35 @@ def test_validate_plan_source_link_rejects_repo_root_relative_path() -> None: errors: list[str] = [] path = Path("docs/ouroboros/README.md").resolve() - module.validate_plan_source_link(path, "../../ouroboros_plan_v2.txt", errors) + handled = module.validate_plan_source_link(path, "../../ouroboros_plan_v2.txt", errors) + assert handled is True assert errors assert "invalid plan link path" in errors[0] assert "must resolve to docs/ouroboros/source/ouroboros_plan_v2.txt" in errors[0] + + +def test_validate_plan_source_link_accepts_fragment_suffix() -> None: + module = _load_module() + errors: list[str] = [] + path = Path("docs/ouroboros/README.md").resolve() + + handled = module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt#sec", errors) + + assert handled is False + assert errors == [] + + +def test_validate_links_avoids_duplicate_error_for_invalid_plan_link(tmp_path) -> None: + module = _load_module() + errors: list[str] = [] + doc = tmp_path / "doc.md" + doc.write_text( + "[v2](/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt)\n", + encoding="utf-8", + ) + + module.validate_links(doc, doc.read_text(encoding="utf-8"), errors) + + assert len(errors) == 1 + assert "invalid plan link path" in errors[0] -- 2.49.1