diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9ee06db..d992e70 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40f340d..d2e5f1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/ diff --git a/scripts/validate_docs_sync.py b/scripts/validate_docs_sync.py new file mode 100644 index 0000000..0dc83c2 --- /dev/null +++ b/scripts/validate_docs_sync.py @@ -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[^)]+)\)") +ENDPOINT_ROW_PATTERN = re.compile( + r"^\|\s*`(?P(?: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()) diff --git a/tests/test_validate_docs_sync.py b/tests/test_validate_docs_sync.py new file mode 100644 index 0000000..5c8309f --- /dev/null +++ b/tests/test_validate_docs_sync.py @@ -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 == []