Merge pull request 'docs validator: enforce source path policy for ouroboros plan links (#357)' (#360) from feature/issue-357-docs-source-path-validator into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 33s
All checks were successful
Gitea CI / test (push) Successful in 33s
Reviewed-on: #360
This commit was merged in pull request #360.
This commit is contained in:
@@ -19,9 +19,20 @@ META_PATTERN = re.compile(
|
|||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
ID_PATTERN = re.compile(r"\b(?:REQ|RULE|TASK|TEST|DOC)-[A-Z0-9-]+-\d{3}\b")
|
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)
|
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>[^)]+)\)")
|
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)
|
LINE_DEF_PATTERN = re.compile(
|
||||||
|
r"^-\s+`(?P<id>(?:REQ|RULE|TASK|TEST|DOC)-[A-Z0-9-]+-\d{3})`.*$",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
PLAN_LINK_PATTERN = re.compile(r"ouroboros_plan_v(?P<version>[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]:
|
def iter_docs() -> list[Path]:
|
||||||
@@ -40,15 +51,47 @@ def validate_metadata(path: Path, text: str, errors: list[str], doc_ids: dict[st
|
|||||||
doc_ids[doc_id] = path
|
doc_ids[doc_id] = path
|
||||||
|
|
||||||
|
|
||||||
|
def validate_plan_source_link(path: Path, link: str, errors: list[str]) -> bool:
|
||||||
|
normalized = link.strip()
|
||||||
|
# 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 False
|
||||||
|
|
||||||
|
version = match.group("version")
|
||||||
|
expected_target = ALLOWED_PLAN_TARGETS[version]
|
||||||
|
if link_path.startswith("/"):
|
||||||
|
errors.append(
|
||||||
|
f"{path}: invalid plan link path -> {link} "
|
||||||
|
f"(use ./source/ouroboros_plan_v{version}.txt)"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
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:
|
def validate_links(path: Path, text: str, errors: list[str]) -> None:
|
||||||
for m in LINK_PATTERN.finditer(text):
|
for m in LINK_PATTERN.finditer(text):
|
||||||
link = m.group("link").strip()
|
link = m.group("link").strip()
|
||||||
if not link or link.startswith("http") or link.startswith("#"):
|
if not link or link.startswith("http") or link.startswith("#"):
|
||||||
continue
|
continue
|
||||||
if link.startswith("/"):
|
if validate_plan_source_link(path, link, errors):
|
||||||
target = Path(link)
|
continue
|
||||||
|
link_path = link.split("#", 1)[0].strip()
|
||||||
|
if link_path.startswith("/"):
|
||||||
|
target = Path(link_path)
|
||||||
else:
|
else:
|
||||||
target = (path.parent / link).resolve()
|
target = (path.parent / link_path).resolve()
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
errors.append(f"{path}: broken link -> {link}")
|
errors.append(f"{path}: broken link -> {link}")
|
||||||
|
|
||||||
@@ -61,7 +104,9 @@ def collect_ids(path: Path, text: str, defs: dict[str, Path], refs: dict[str, se
|
|||||||
refs.setdefault(idv, set()).add(path)
|
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):
|
for m in LINE_DEF_PATTERN.finditer(text):
|
||||||
line = m.group(0)
|
line = m.group(0)
|
||||||
item_id = m.group("id")
|
item_id = m.group("id")
|
||||||
|
|||||||
81
tests/test_validate_ouroboros_docs.py
Normal file
81
tests/test_validate_ouroboros_docs.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
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 == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_plan_source_link_rejects_root_relative_path() -> None:
|
||||||
|
module = _load_module()
|
||||||
|
errors: list[str] = []
|
||||||
|
path = Path("docs/ouroboros/README.md").resolve()
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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]
|
||||||
Reference in New Issue
Block a user