Files
hermes-agent/tests/skills/test_skill_docs_contract.py
treatux 2a1ec15a6f docs(website): regenerate the skill docs from the shipped skill tree
website/scripts/generate-skill-docs.py is the documented source of both catalogs
and of the per-skill pages, but nothing compares its output with what is
committed, so the committed copies drifted:

- optional-skills-catalog.md was missing agent-merge-conflict-arbiter and listed
  pr-lens under blockchain (it lives in software-development);
- skills-catalog.md still listed merge-reconciler, which #98539 moved out of the
  bundled set;
- 196 pages and both catalogs carried Windows path separators — in the Path
  column and inside GitHub blob links, where a backslash is a broken URL.

This commit is the generator's output (re-running it on this branch is a no-op),
plus the orphan page #98539 left behind for merge-reconciler: no skill backs it,
its catalog row is gone, and no page links to it.

tests/skills/test_skill_docs_contract.py is the guard: the next skill that ships
without a catalog row, or a page regenerated on Windows, fails there instead of
on the published page.
2026-09-20 12:54:07 -07:00

94 lines
4.2 KiB
Python

"""Contract: the committed skill docs stay in sync with the shipped skill tree.
``website/scripts/generate-skill-docs.py`` is the documented source of both catalogs and of the
per-skill pages under ``website/docs/user-guide/skills/``. The *Docs Site Checks* workflow
regenerates them before building the site, but nothing compares that output with what is
committed, so the committed copies — which GitHub renders and which contributors read and copy
from — drifted:
- ``optional-skills-catalog.md`` was missing ``agent-merge-conflict-arbiter`` and listed
``pr-lens`` under ``blockchain`` instead of ``software-development``;
- both catalogs and 196 per-skill pages carried Windows path separators in the ``Path`` column
(``skills/apple\\apple-notes``) and inside GitHub blob links, where a backslash makes the URL
resolve to nothing.
These tests assert the relationships the generator guarantees, so the next skill that ships
without a catalog entry (or a page regenerated on a Windows host) fails here instead of on the
published page. They read the shipped ``.md`` artifacts and the skill tree — never source text.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
CATALOGS = {
"skills": REPO_ROOT / "website" / "docs" / "reference" / "skills-catalog.md",
"optional-skills": REPO_ROOT / "website" / "docs" / "reference" / "optional-skills-catalog.md",
}
SKILL_PAGES = REPO_ROOT / "website" / "docs" / "user-guide" / "skills"
_ROW_RE = re.compile(r"^\|\s*\[(?:\*\*)?`?([^`*\]]+)`?(?:\*\*)?\]\(([^)]+)\)", re.MULTILINE)
_PATH_ROW_RE = re.compile(r"^\|\s*Path\s*\|\s*`([^`]+)`", re.MULTILINE)
_MD_LINK_RE = re.compile(r"\]\((\.[^)]+\.md)(?:#[^)]*)?\)")
def _shipped_skills(root: str) -> set[str]:
return {p.parent.name for p in (REPO_ROOT / root).rglob("SKILL.md")}
def _catalog_rows(path: Path) -> dict[str, str]:
return {m.group(1).strip(): m.group(2).strip() for m in _ROW_RE.finditer(path.read_text(encoding="utf-8"))}
@pytest.mark.parametrize("root", sorted(CATALOGS))
def test_catalog_lists_every_shipped_skill(root):
"""Every skill shipped in the tree has a row in its catalog."""
listed = set(_catalog_rows(CATALOGS[root]))
missing = sorted(_shipped_skills(root) - listed)
assert not missing, f"{CATALOGS[root].name} does not list shipped skills: {missing}"
@pytest.mark.parametrize("root", sorted(CATALOGS))
def test_catalog_rows_resolve_to_a_shipped_skill(root):
"""No catalog row outlives the skill it documents."""
shipped = _shipped_skills(root)
dangling = sorted(name for name in _catalog_rows(CATALOGS[root]) if name not in shipped)
assert not dangling, f"{CATALOGS[root].name} lists skills that no longer ship: {dangling}"
@pytest.mark.parametrize("root", sorted(CATALOGS))
def test_catalog_links_resolve(root):
"""Every relative link in a catalog points at a file that exists."""
catalog = CATALOGS[root]
broken = sorted(
target for target in set(_MD_LINK_RE.findall(catalog.read_text(encoding="utf-8")))
if not (catalog.parent / target).resolve().exists()
)
assert not broken, f"{catalog.name} has dangling links: {broken}"
def test_generated_pages_use_posix_separators():
"""A ``Path`` row or relative link carrying a backslash means the page was not regenerated.
The generator emits POSIX separators; a backslash survives only in a stale copy, and inside a
GitHub blob link it is a silently broken URL.
"""
offenders: list[str] = []
for page in sorted(SKILL_PAGES.rglob("*.md")):
text = page.read_text(encoding="utf-8")
for bad in _PATH_ROW_RE.findall(text):
if "\\" in bad:
offenders.append(f"{page.relative_to(REPO_ROOT)}: Path `{bad}`")
for target in _MD_LINK_RE.findall(text):
if "\\" in target:
offenders.append(f"{page.relative_to(REPO_ROOT)}: link ({target})")
for catalog in CATALOGS.values():
for bad in _PATH_ROW_RE.findall(catalog.read_text(encoding="utf-8")):
if "\\" in bad:
offenders.append(f"{catalog.name}: Path `{bad}`")
assert not offenders, "Windows separators in generated docs:\n" + "\n".join(offenders[:20])