Files
cathrynlavery__diagram-design/scripts/verify-docs-sync.py
T
Cathryn Lavery 09df49d8d1 Add named client profiles (#61)
* feat: add client profiles

* ci: pin shell to bash for docs_sync step

The docs_sync step runs two commands in a multi-line block. Without a
pinned shell, the windows-latest matrix leg uses pwsh, where a non-zero
exit from the first command may not abort the block, masking a gate
failure. Pin shell: bash to match the icons and summary steps.
2026-08-14 14:28:44 -07:00

179 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Verify that routing and browsing surfaces stay in sync with the skill.
Five drift classes, each of which has shipped before:
1. The SKILL.md frontmatter description is the only text an agent sees before
deciding to load the skill — every visual type in the selection table must
keep a lexical hook there.
2. The gallery (assets/index.html) must reach every shipped example, and every
gallery tab must point at a file that exists.
3. Every concrete file named in README.md's architecture tree must exist.
4. Every relative references/*.md link in SKILL.md must resolve.
5. Claude and Pi profile surfaces must both route to the profile reference.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SKILL = ROOT / "skills/diagram-design/SKILL.md"
GALLERY = ROOT / "skills/diagram-design/assets/index.html"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
README = ROOT / "README.md"
VARIANTS = ("", "-dark", "-full")
# Types whose selection-table name differs from its description vocabulary.
DESCRIPTION_ALIASES = {
"bar chart": "bar",
"line chart": "line",
"scatter plot": "scatter",
}
PROFILE_SURFACES = (
Path("commands/profile.md"),
Path("prompts/profile.md"),
)
def normalized(text: str) -> str:
text = text.casefold()
text = re.sub(r"\s*/\s*", "/", text)
return re.sub(r"\s+", " ", text)
def frontmatter_description(markdown: str) -> str:
parts = markdown.split("---")
if len(parts) < 3:
return ""
match = re.search(r"^description:\s*(.+)$", parts[1], re.MULTILINE)
return match.group(1).strip() if match else ""
def selection_table_types(markdown: str) -> list[str]:
start = markdown.find("### Visual-type guide")
end = markdown.find("Rules of thumb", start)
if start < 0 or end < 0:
return []
names = re.findall(r"^\|[^|]*\|\s*\*\*([^*]+)\*\*\s*\|", markdown[start:end], re.MULTILINE)
return [name.strip() for name in names]
def check_description(errors: list[str]) -> None:
markdown = SKILL.read_text(encoding="utf-8")
description = normalized(frontmatter_description(markdown))
if not description:
errors.append("SKILL.md frontmatter description is missing")
return
types = selection_table_types(markdown)
if len(types) != 27:
errors.append(f"expected 27 visual types in the selection table; found {len(types)}")
for name in types:
key = normalized(name)
key = DESCRIPTION_ALIASES.get(key, key)
if key not in description:
errors.append(
f"description lost the lexical hook for type {name!r} "
f"(expected {key!r} in the SKILL.md frontmatter description)"
)
def gallery_types(source: str) -> list[str]:
return re.findall(r'data-type="([^"]+)"', source)
def check_gallery(errors: list[str]) -> None:
source = GALLERY.read_text(encoding="utf-8")
types = gallery_types(source)
if not types:
errors.append("gallery has no data-type tabs")
return
reachable = {f"example-{name}{variant}.html" for name in types for variant in VARIANTS}
on_disk = {path.name for path in ASSET_DIR.glob("example-*.html")}
for name in sorted(on_disk - reachable):
errors.append(f"gallery cannot reach shipped example {name}; add a tab to assets/index.html")
for name in sorted(types):
if f"example-{name}.html" not in on_disk:
errors.append(f"gallery tab {name!r} points at a missing example-{name}.html")
def readme_tree_tokens(markdown: str) -> list[str]:
blocks = re.findall(r"```\n(diagram-design/\n.*?)```", markdown, re.DOTALL)
tokens: list[str] = []
for block in blocks:
tokens.extend(
re.findall(r"([A-Za-z0-9][A-Za-z0-9_.*-]*\.(?:md|html|py|yml|yaml|json|txt|mmd|drawio|png))", block)
)
return tokens
def check_readme_tree(errors: list[str]) -> None:
markdown = README.read_text(encoding="utf-8")
tokens = readme_tree_tokens(markdown)
if not tokens:
errors.append("README architecture tree not found or names no files")
return
for token in sorted(set(tokens)):
matches = list(ROOT.rglob(token))
if not matches:
errors.append(f"README architecture tree names {token!r} but no such file exists")
def skill_reference_links(markdown: str) -> list[str]:
"""Return direct relative links from SKILL.md into references/."""
return re.findall(
r"\]\((references/[A-Za-z0-9][A-Za-z0-9_.-]*\.md)(?:#[^)]*)?\)",
markdown,
)
def check_skill_reference_links(
errors: list[str], markdown: str, skill_directory: Path
) -> None:
for target in sorted(set(skill_reference_links(markdown))):
if not (skill_directory / target).is_file():
errors.append(f"SKILL.md links to missing reference {target!r}")
def check_profile_surfaces(errors: list[str], root: Path) -> None:
reference = root / "skills/diagram-design/references/profiles.md"
if not reference.is_file():
errors.append("profile source of truth is missing: skills/diagram-design/references/profiles.md")
for relative in PROFILE_SURFACES:
path = root / relative
if not path.is_file():
errors.append(f"profile surface is missing: {relative.as_posix()}")
continue
if "references/profiles.md" not in path.read_text(encoding="utf-8"):
errors.append(
f"profile surface does not route to references/profiles.md: {relative.as_posix()}"
)
def main() -> int:
errors: list[str] = []
check_description(errors)
check_gallery(errors)
check_readme_tree(errors)
check_skill_reference_links(
errors,
SKILL.read_text(encoding="utf-8"),
SKILL.parent,
)
check_profile_surfaces(errors, ROOT)
if errors:
print("FAIL docs sync")
for error in errors:
print(f" - {error}")
return 1
print(
"OK docs sync: description hooks, gallery reachability, README tree, "
"reference links, profile surfaces"
)
return 0
if __name__ == "__main__":
sys.exit(main())