diff --git a/scripts/validate_docs_sync.py b/scripts/validate_docs_sync.py
new file mode 100644
index 0000000..87cf519
--- /dev/null
+++ b/scripts/validate_docs_sync.py
@@ -0,0 +1,134 @@
+#!/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["README.md"]
+ if file_name == "README.md"
+ else REQUIRED_FILES["CLAUDE.md"]
+ )
+ 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_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..793c795
--- /dev/null
+++ b/tests/test_validate_docs_sync.py
@@ -0,0 +1,102 @@
+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_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 == []