docs validator: add docs sync invariants with tests (#363) #365
@@ -47,6 +47,9 @@ jobs:
|
||||
- name: Validate Ouroboros docs
|
||||
run: python3 scripts/validate_ouroboros_docs.py
|
||||
|
||||
- name: Validate docs sync
|
||||
run: python3 scripts/validate_docs_sync.py
|
||||
|
||||
- name: Lint
|
||||
run: ruff check src/ tests/
|
||||
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -44,6 +44,9 @@ jobs:
|
||||
- name: Validate Ouroboros docs
|
||||
run: python3 scripts/validate_ouroboros_docs.py
|
||||
|
||||
- name: Validate docs sync
|
||||
run: python3 scripts/validate_docs_sync.py
|
||||
|
||||
- name: Lint
|
||||
run: ruff check src/ tests/
|
||||
|
||||
|
||||
135
scripts/validate_docs_sync.py
Normal file
135
scripts/validate_docs_sync.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate top-level docs synchronization invariants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(".")
|
||||
REQUIRED_FILES = {
|
||||
"README.md": REPO_ROOT / "README.md",
|
||||
"CLAUDE.md": REPO_ROOT / "CLAUDE.md",
|
||||
"commands": REPO_ROOT / "docs" / "commands.md",
|
||||
"testing": REPO_ROOT / "docs" / "testing.md",
|
||||
"workflow": REPO_ROOT / "docs" / "workflow.md",
|
||||
}
|
||||
|
||||
LINK_PATTERN = re.compile(r"\[[^\]]+\]\((?P<link>[^)]+)\)")
|
||||
ENDPOINT_ROW_PATTERN = re.compile(
|
||||
r"^\|\s*`(?P<endpoint>(?:GET|POST|PUT|PATCH|DELETE)\s+/[^`]*)`\s*\|"
|
||||
)
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def validate_required_files_exist(errors: list[str]) -> None:
|
||||
for name, path in REQUIRED_FILES.items():
|
||||
if not path.exists():
|
||||
errors.append(f"missing required doc file ({name}): {path}")
|
||||
|
||||
|
||||
def validate_links_resolve(doc_path: Path, text: str, errors: list[str]) -> None:
|
||||
for match in LINK_PATTERN.finditer(text):
|
||||
raw_link = match.group("link").strip()
|
||||
if not raw_link or raw_link.startswith("#") or raw_link.startswith("http"):
|
||||
continue
|
||||
link_path = raw_link.split("#", 1)[0].strip()
|
||||
if not link_path:
|
||||
continue
|
||||
if link_path.startswith("/"):
|
||||
errors.append(f"{doc_path}: absolute link is forbidden -> {raw_link}")
|
||||
continue
|
||||
target = (doc_path.parent / link_path).resolve()
|
||||
if not target.exists():
|
||||
errors.append(f"{doc_path}: broken link -> {raw_link}")
|
||||
|
||||
|
||||
def validate_summary_docs_reference_core_docs(errors: list[str]) -> None:
|
||||
required_links = {
|
||||
"README.md": ("docs/workflow.md", "docs/commands.md", "docs/testing.md"),
|
||||
"CLAUDE.md": ("docs/workflow.md", "docs/commands.md"),
|
||||
}
|
||||
for file_name, links in required_links.items():
|
||||
doc_path = REQUIRED_FILES[file_name]
|
||||
text = _read(doc_path)
|
||||
for link in links:
|
||||
if link not in text:
|
||||
errors.append(f"{doc_path}: missing core doc link reference -> {link}")
|
||||
|
||||
|
||||
def collect_command_endpoints(text: str) -> list[str]:
|
||||
endpoints: list[str] = []
|
||||
for line in text.splitlines():
|
||||
match = ENDPOINT_ROW_PATTERN.match(line.strip())
|
||||
if match:
|
||||
endpoints.append(match.group("endpoint"))
|
||||
return endpoints
|
||||
|
||||
|
||||
def validate_commands_endpoint_duplicates(errors: list[str]) -> None:
|
||||
text = _read(REQUIRED_FILES["commands"])
|
||||
endpoints = collect_command_endpoints(text)
|
||||
seen: set[str] = set()
|
||||
duplicates: set[str] = set()
|
||||
for endpoint in endpoints:
|
||||
if endpoint in seen:
|
||||
duplicates.add(endpoint)
|
||||
seen.add(endpoint)
|
||||
for endpoint in sorted(duplicates):
|
||||
errors.append(f"docs/commands.md: duplicated API endpoint row -> {endpoint}")
|
||||
|
||||
|
||||
def validate_testing_doc_has_dynamic_count_guidance(errors: list[str]) -> None:
|
||||
text = _read(REQUIRED_FILES["testing"])
|
||||
if "pytest --collect-only -q" not in text:
|
||||
errors.append(
|
||||
"docs/testing.md: missing dynamic test count guidance "
|
||||
"(pytest --collect-only -q)"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
|
||||
validate_required_files_exist(errors)
|
||||
if errors:
|
||||
print("[FAIL] docs sync validation failed")
|
||||
for err in errors:
|
||||
print(f"- {err}")
|
||||
return 1
|
||||
|
||||
readme_text = _read(REQUIRED_FILES["README.md"])
|
||||
claude_text = _read(REQUIRED_FILES["CLAUDE.md"])
|
||||
validate_links_resolve(REQUIRED_FILES["README.md"], readme_text, errors)
|
||||
validate_links_resolve(REQUIRED_FILES["CLAUDE.md"], claude_text, errors)
|
||||
validate_links_resolve(
|
||||
REQUIRED_FILES["commands"], _read(REQUIRED_FILES["commands"]), errors
|
||||
)
|
||||
validate_links_resolve(REQUIRED_FILES["testing"], _read(REQUIRED_FILES["testing"]), errors)
|
||||
validate_links_resolve(
|
||||
REQUIRED_FILES["workflow"], _read(REQUIRED_FILES["workflow"]), errors
|
||||
)
|
||||
|
||||
validate_summary_docs_reference_core_docs(errors)
|
||||
validate_commands_endpoint_duplicates(errors)
|
||||
validate_testing_doc_has_dynamic_count_guidance(errors)
|
||||
|
||||
if errors:
|
||||
print("[FAIL] docs sync validation failed")
|
||||
for err in errors:
|
||||
print(f"- {err}")
|
||||
return 1
|
||||
|
||||
print("[OK] docs sync validated")
|
||||
print("[OK] summary docs link to core docs and links resolve")
|
||||
print("[OK] commands endpoint rows have no duplicates")
|
||||
print("[OK] testing doc includes dynamic count guidance")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
123
tests/test_validate_docs_sync.py
Normal file
123
tests/test_validate_docs_sync.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_module():
|
||||
script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_docs_sync.py"
|
||||
spec = importlib.util.spec_from_file_location("validate_docs_sync", 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_collect_command_endpoints_parses_markdown_table_rows() -> None:
|
||||
module = _load_module()
|
||||
text = "\n".join(
|
||||
[
|
||||
"| Endpoint | Description |",
|
||||
"|----------|-------------|",
|
||||
"| `GET /api/status` | status |",
|
||||
"| `POST /api/run` | run |",
|
||||
"| not-a-row | ignored |",
|
||||
]
|
||||
)
|
||||
endpoints = module.collect_command_endpoints(text)
|
||||
assert endpoints == ["GET /api/status", "POST /api/run"]
|
||||
|
||||
|
||||
def test_validate_links_resolve_detects_absolute_and_broken_links(tmp_path) -> None:
|
||||
module = _load_module()
|
||||
doc = tmp_path / "doc.md"
|
||||
existing = tmp_path / "ok.md"
|
||||
existing.write_text("# ok\n", encoding="utf-8")
|
||||
doc.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[ok](./ok.md)",
|
||||
"[abs](/tmp/nowhere.md)",
|
||||
"[broken](./missing.md)",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
module.validate_links_resolve(doc, doc.read_text(encoding="utf-8"), errors)
|
||||
|
||||
assert any("absolute link is forbidden" in err for err in errors)
|
||||
assert any("broken link" in err for err in errors)
|
||||
|
||||
|
||||
def test_validate_summary_docs_reference_core_docs(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
errors: list[str] = []
|
||||
fake_docs = {
|
||||
str(module.REQUIRED_FILES["README.md"]): (
|
||||
"docs/workflow.md docs/commands.md docs/testing.md"
|
||||
),
|
||||
str(module.REQUIRED_FILES["CLAUDE.md"]): "docs/workflow.md docs/commands.md",
|
||||
}
|
||||
|
||||
def fake_read(path: Path) -> str:
|
||||
return fake_docs[str(path)]
|
||||
|
||||
monkeypatch.setattr(module, "_read", fake_read)
|
||||
module.validate_summary_docs_reference_core_docs(errors)
|
||||
assert errors == []
|
||||
|
||||
|
||||
def test_validate_summary_docs_reference_core_docs_reports_missing_links(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
module = _load_module()
|
||||
errors: list[str] = []
|
||||
fake_docs = {
|
||||
str(module.REQUIRED_FILES["README.md"]): "docs/workflow.md",
|
||||
str(module.REQUIRED_FILES["CLAUDE.md"]): "docs/workflow.md",
|
||||
}
|
||||
|
||||
def fake_read(path: Path) -> str:
|
||||
return fake_docs[str(path)]
|
||||
|
||||
monkeypatch.setattr(module, "_read", fake_read)
|
||||
module.validate_summary_docs_reference_core_docs(errors)
|
||||
|
||||
assert any("README.md" in err and "docs/commands.md" in err for err in errors)
|
||||
assert any("README.md" in err and "docs/testing.md" in err for err in errors)
|
||||
assert any("CLAUDE.md" in err and "docs/commands.md" in err for err in errors)
|
||||
|
||||
|
||||
def test_validate_commands_endpoint_duplicates_reports_duplicates(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
errors: list[str] = []
|
||||
text = "\n".join(
|
||||
[
|
||||
"| `GET /api/status` | status |",
|
||||
"| `GET /api/status` | duplicate |",
|
||||
]
|
||||
)
|
||||
|
||||
def fake_read(path: Path) -> str:
|
||||
assert path == module.REQUIRED_FILES["commands"]
|
||||
return text
|
||||
|
||||
monkeypatch.setattr(module, "_read", fake_read)
|
||||
module.validate_commands_endpoint_duplicates(errors)
|
||||
assert errors
|
||||
assert "duplicated API endpoint row -> GET /api/status" in errors[0]
|
||||
|
||||
|
||||
def test_validate_testing_doc_has_dynamic_count_guidance(monkeypatch) -> None:
|
||||
module = _load_module()
|
||||
errors: list[str] = []
|
||||
|
||||
def fake_read(path: Path) -> str:
|
||||
assert path == module.REQUIRED_FILES["testing"]
|
||||
return "Use pytest --collect-only -q for dynamic counts."
|
||||
|
||||
monkeypatch.setattr(module, "_read", fake_read)
|
||||
module.validate_testing_doc_has_dynamic_count_guidance(errors)
|
||||
assert errors == []
|
||||
Reference in New Issue
Block a user