|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | """Validate skill structure and frontmatter. |
3 | 3 |
|
4 | | -Checks: |
| 4 | +Checks (errors — block PRs): |
5 | 5 | 1. Every skill directory has a SKILL.md file |
6 | 6 | 2. SKILL.md has valid YAML frontmatter per best practices: |
7 | 7 | - name: required, ≤64 chars, lowercase letters/numbers/hyphens only, |
8 | 8 | no XML tags, no reserved words ("anthropic", "claude") |
9 | 9 | - description: required, non-empty, ≤1024 chars, no XML tags |
10 | 10 | 3. Local skill directories are registered in install_skills.sh |
11 | 11 | (skill-list variables are auto-discovered, not hardcoded) |
| 12 | +
|
| 13 | +Quality warnings (non-blocking): |
| 14 | +4. Description should contain "Use when" trigger phrases |
| 15 | +5. SKILL.md body should be under 500 lines (use reference files for overflow) |
| 16 | +6. Code blocks should have language tags |
| 17 | +7. Referenced files (markdown links) should exist |
12 | 18 | """ |
13 | 19 |
|
14 | 20 | import re |
|
26 | 32 | XML_TAG_RE = re.compile(r"<[^>]+>") |
27 | 33 |
|
28 | 34 |
|
| 35 | +CODE_BLOCK_RE = re.compile(r"^```(\w*)$", re.MULTILINE) |
| 36 | +MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") |
| 37 | +MAX_BODY_LINES = 500 |
| 38 | + |
| 39 | + |
| 40 | +def quality_warnings(skill_dir: Path, content: str, frontmatter: dict) -> list[str]: |
| 41 | + """Run non-blocking quality checks and return warnings.""" |
| 42 | + warnings = [] |
| 43 | + |
| 44 | + # Check description contains "Use when" trigger phrases |
| 45 | + desc = str(frontmatter.get("description", "")) |
| 46 | + if desc and "use when" not in desc.lower(): |
| 47 | + warnings.append( |
| 48 | + f'{skill_dir.name}: description lacks "Use when" trigger phrases ' |
| 49 | + f"(helps Claude decide when to activate the skill)" |
| 50 | + ) |
| 51 | + |
| 52 | + # Check body length (lines below frontmatter) |
| 53 | + body = re.sub(r"^---\n.+?\n---\n?", "", content, count=1, flags=re.DOTALL) |
| 54 | + body_lines = len(body.strip().splitlines()) |
| 55 | + if body_lines > MAX_BODY_LINES: |
| 56 | + warnings.append( |
| 57 | + f"{skill_dir.name}: SKILL.md body is {body_lines} lines " |
| 58 | + f"(>{MAX_BODY_LINES}). Consider moving content to reference files." |
| 59 | + ) |
| 60 | + |
| 61 | + # Check code blocks have language tags |
| 62 | + # Every pair of ``` markers forms a block; even-indexed matches (0,2,4..) |
| 63 | + # are opening markers, odd-indexed are closing markers. |
| 64 | + all_fences = list(CODE_BLOCK_RE.finditer(content)) |
| 65 | + opening_fences = [all_fences[i] for i in range(0, len(all_fences), 2)] |
| 66 | + untagged = sum(1 for m in opening_fences if not m.group(1)) |
| 67 | + if untagged > 0: |
| 68 | + warnings.append( |
| 69 | + f"{skill_dir.name}: {untagged} code block(s) missing language tags " |
| 70 | + f"(use ```python, ```sql, ```yaml, etc.)" |
| 71 | + ) |
| 72 | + |
| 73 | + # Check referenced markdown files exist |
| 74 | + for match in MD_LINK_RE.finditer(content): |
| 75 | + link_target = match.group(2) |
| 76 | + # Only check relative .md links (not URLs, not anchors) |
| 77 | + if ( |
| 78 | + not link_target.startswith("http") |
| 79 | + and not link_target.startswith("#") |
| 80 | + and link_target.endswith(".md") |
| 81 | + ): |
| 82 | + ref_path = skill_dir / link_target |
| 83 | + if not ref_path.exists(): |
| 84 | + warnings.append( |
| 85 | + f"{skill_dir.name}: referenced file '{link_target}' not found" |
| 86 | + ) |
| 87 | + |
| 88 | + return warnings |
| 89 | + |
| 90 | + |
29 | 91 | def parse_frontmatter(content: str) -> dict | None: |
30 | 92 | """Extract YAML frontmatter from markdown content.""" |
31 | 93 | match = re.match(r"^---\n(.+?)\n---", content, re.DOTALL) |
@@ -117,6 +179,7 @@ def get_local_skill_dirs() -> set[str]: |
117 | 179 |
|
118 | 180 | def main() -> int: |
119 | 181 | errors: list[str] = [] |
| 182 | + warnings: list[str] = [] |
120 | 183 | actual_skills = get_local_skill_dirs() |
121 | 184 |
|
122 | 185 | # --- Validate each skill directory's SKILL.md and frontmatter --- |
@@ -153,6 +216,10 @@ def main() -> int: |
153 | 216 | for err in validate_description(str(frontmatter["description"])): |
154 | 217 | errors.append(f"{skill_dir.name}: {err}") |
155 | 218 |
|
| 219 | + # Quality warnings (non-blocking) |
| 220 | + for warn in quality_warnings(skill_dir, content, frontmatter): |
| 221 | + warnings.append(warn) |
| 222 | + |
156 | 223 | # --- Cross-reference with install_skills.sh --- |
157 | 224 | install_content = INSTALL_SCRIPT.read_text() |
158 | 225 | skill_vars, composite_vars = parse_skill_variables(install_content) |
@@ -182,6 +249,13 @@ def main() -> int: |
182 | 249 | errors.append(f"Skills in {var_name} but no directory found: {sorted(missing)}") |
183 | 250 |
|
184 | 251 | # --- Report --- |
| 252 | + # Surface warnings (non-blocking) before errors |
| 253 | + if warnings: |
| 254 | + print(f"Quality warnings ({len(warnings)}):\n") |
| 255 | + for warning in warnings: |
| 256 | + print(f"::warning::{warning}") |
| 257 | + print() |
| 258 | + |
185 | 259 | if errors: |
186 | 260 | print("Skill validation failed:\n") |
187 | 261 | for error in errors: |
|
0 commit comments