"""Test SKILL.md structure and internal reference integrity.
Migrated from scripts/validate_skills.py into pytest for better
discovery, parametrization, and CI reporting.
"""
import re
from pathlib import Path
import pytest
from conftest import SKILLS_DIR
SKILL_DIRS = sorted(
d for d in SKILLS_DIR.iterdir()
if d.is_dir() and not d.name.startswith(".")
)
@pytest.fixture(params=SKILL_DIRS, ids=[d.name for d in SKILL_DIRS])
def skill_dir(request):
return request.param
class TestSkillMdExists:
@staticmethod
def test_skill_md_present(skill_dir):
assert (skill_dir / "SKILL.md").exists(), (
f"{skill_dir.name}/ missing SKILL.md"
)
class TestSkillMdContent:
@staticmethod
def test_not_suspiciously_short(skill_dir):
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
pytest.skip("no SKILL.md")
content = skill_md.read_text(encoding="utf-8")
assert len(content.strip()) >= 50, (
f"{skill_dir.name}/SKILL.md too short ({len(content.strip())} chars)"
)
@staticmethod
def test_not_too_long(skill_dir):
"""SKILL.md should be under 500 lines (Anthropic progressive disclosure guideline)."""
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
pytest.skip("no SKILL.md")
lines = skill_md.read_text(encoding="utf-8").splitlines()
assert len(lines) <= 500, (
f"{skill_dir.name}/SKILL.md too long ({len(lines)} lines, max 500)"
)
@staticmethod
def test_description_quality(skill_dir):
"""YAML frontmatter description must be non-empty (>= 5 words)."""
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
pytest.skip("no SKILL.md")
content = skill_md.read_text(encoding="utf-8")
fm_match = re.search(r"^---\n(.*?)\n---", content, re.DOTALL)
if not fm_match:
pytest.skip("no YAML frontmatter")
fm = fm_match.group(1)
dm = re.search(r"description:\s*>\s*\n(.*?)(?=\n\w|\Z)", fm, re.DOTALL)
if dm:
desc = " ".join(dm.group(1).split())
else:
dm = re.search(r"description:\s*(.+)", fm)
if not dm:
pytest.fail(f"{skill_dir.name}/SKILL.md missing description in frontmatter")
desc = dm.group(1).strip().strip("\"'")
word_count = len(desc.split())
assert word_count >= 5, (
f"{skill_dir.name}/SKILL.md description too short ({word_count} words, min 5)"
)
assert word_count < 100, (
f"{skill_dir.name}/SKILL.md description too long ({word_count} words, max 99)"
)
class TestScriptPaths:
"""SKILL.md must use ${CLAUDE_SKILL_DIR} for script paths, not hardcoded relative paths.
See: https://code.claude.com/docs/en/skills
Plugin skills run in the user's project directory, so relative paths to
plugin-bundled scripts will fail. ${CLAUDE_SKILL_DIR} is auto-resolved
by Claude Code to the skill's absolute directory path.
"""
HARDCODED_SCRIPT_RE = re.compile(
r"(?:bash|python3?|sh|\.venv/bin/python3?)\s+skills/[^/]+/scripts/", re.MULTILINE
)
def test_no_hardcoded_script_paths(self, skill_dir):
"""Scripts in code blocks must use ${CLAUDE_SKILL_DIR}, not relative paths.
Plugin skills run in the user's project directory, so
'bash skills/foo/scripts/bar.sh' will fail with file not found.
Use 'bash ${CLAUDE_SKILL_DIR}/scripts/bar.sh' instead.
"""
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
pytest.skip("no SKILL.md")
content = skill_md.read_text(encoding="utf-8")
code_blocks = re.findall(r"```[^\n]*\n(.*?)```", content, re.DOTALL)
violations = []
for block in code_blocks:
for match in self.HARDCODED_SCRIPT_RE.finditer(block):
violations.append(match.group(0).strip())
assert not violations, (
f"{skill_dir.name}/SKILL.md has hardcoded script paths in code blocks "
f"(use ${{CLAUDE_SKILL_DIR}}/scripts/ instead): {violations}"
)
class TestInternalLinks:
@staticmethod
def test_reference_links_resolve(skill_dir):
"""All [text](references/...) links in SKILL.md must point to existing files."""
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
pytest.skip("no SKILL.md")
content = skill_md.read_text(encoding="utf-8")
links = re.findall(r"\[.*?\]\((references/[^)]+)\)", content)
broken = []
for link in links:
target = skill_dir / link
if not target.exists():
broken.append(link)
assert not broken, (
f"{skill_dir.name}/SKILL.md has broken links: {broken}"
)
@staticmethod
def test_no_orphan_references(skill_dir):
"""Reference files should be linked from SKILL.md."""
skill_md = skill_dir / "SKILL.md"
refs_dir = skill_dir / "references"
if not skill_md.exists() or not refs_dir.exists():
pytest.skip("no SKILL.md or references/")
content = skill_md.read_text(encoding="utf-8")
links = re.findall(r"\[.*?\]\((references/[^)]+)\)", content)
linked = {(skill_dir / link).resolve() for link in links}
orphans = []
for ref_file in refs_dir.glob("*.md"):
if ref_file.resolve() not in linked:
orphans.append(ref_file.name)
if orphans:
pytest.warns(UserWarning, match="orphan")
@staticmethod
def test_long_references_have_toc(skill_dir):
"""Reference files over 100 lines should have a TOC (Anthropic guideline)."""
refs_dir = skill_dir / "references"
if not refs_dir.exists():
pytest.skip("no references/")
poorly_structured = []
for ref_file in refs_dir.glob("*.md"):
content = ref_file.read_text(encoding="utf-8")
lines = content.splitlines()
if len(lines) > 100:
toc_links = len(re.findall(r"\[.*?\]\(#.*?\)", content))
if toc_links < 3:
poorly_structured.append(
f"{ref_file.name} ({len(lines)} lines, {toc_links} TOC links)")
assert not poorly_structured, (
f"{skill_dir.name}/ has long reference files lacking structure: {poorly_structured}"
)