docs validator: handle plan link fragments and avoid duplicate link errors
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 32s

This commit is contained in:
agentson
2026-03-01 21:20:06 +09:00
parent 117657d13f
commit d1ef79f385
2 changed files with 50 additions and 14 deletions

View File

@@ -51,27 +51,33 @@ 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]) -> None: def validate_plan_source_link(path: Path, link: str, errors: list[str]) -> bool:
normalized = link.strip() 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: if not match:
return return False
version = match.group("version") version = match.group("version")
expected_target = ALLOWED_PLAN_TARGETS[version] expected_target = ALLOWED_PLAN_TARGETS[version]
if normalized.startswith("/"): if link_path.startswith("/"):
errors.append( errors.append(
f"{path}: invalid plan link path -> {link} " f"{path}: invalid plan link path -> {link} "
f"(use ./source/ouroboros_plan_v{version}.txt)" 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: if resolved_target != expected_target:
errors.append( errors.append(
f"{path}: invalid plan link path -> {link} " f"{path}: invalid plan link path -> {link} "
f"(must resolve to docs/ouroboros/source/ouroboros_plan_v{version}.txt)" 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:
@@ -79,11 +85,13 @@ def validate_links(path: Path, text: str, errors: list[str]) -> None:
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
validate_plan_source_link(path, link, errors) if validate_plan_source_link(path, link, errors):
if link.startswith("/"): continue
target = Path(link) 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}")

View File

@@ -19,8 +19,8 @@ def test_validate_plan_source_link_accepts_canonical_source_path() -> None:
errors: list[str] = [] errors: list[str] = []
path = Path("docs/ouroboros/README.md").resolve() path = Path("docs/ouroboros/README.md").resolve()
module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt", errors) assert module.validate_plan_source_link(path, "./source/ouroboros_plan_v2.txt", errors) is False
module.validate_plan_source_link(path, "./source/ouroboros_plan_v3.txt", errors) assert module.validate_plan_source_link(path, "./source/ouroboros_plan_v3.txt", errors) is False
assert errors == [] assert errors == []
@@ -30,12 +30,13 @@ def test_validate_plan_source_link_rejects_root_relative_path() -> None:
errors: list[str] = [] errors: list[str] = []
path = Path("docs/ouroboros/README.md").resolve() path = Path("docs/ouroboros/README.md").resolve()
module.validate_plan_source_link( handled = module.validate_plan_source_link(
path, path,
"/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt", "/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt",
errors, errors,
) )
assert handled is True
assert errors assert errors
assert "invalid plan link path" in errors[0] assert "invalid plan link path" in errors[0]
assert "use ./source/ouroboros_plan_v2.txt" 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] = [] errors: list[str] = []
path = Path("docs/ouroboros/README.md").resolve() 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 errors
assert "invalid plan link path" in errors[0] assert "invalid plan link path" in errors[0]
assert "must resolve to docs/ouroboros/source/ouroboros_plan_v2.txt" 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]