#!/usr/bin/env node /** * validate-skills.js * * Validates every skill in skills/ against the rules in docs/skill-anatomy.md. * * Checks (errors block CI): * - SKILL.md exists in every skill directory * - YAML frontmatter present with 'name' and 'description' fields * - frontmatter 'name' matches the directory name * - directory name is lowercase-hyphen-separated (skill-anatomy.md: Naming Conventions) * - description does not exceed 1024 characters * - description includes a 'when to use' trigger (skill-anatomy.md: Required) * - required sections are present * * Checks (warnings, do not block CI): * - cross-skill references point to known skills * * Exit codes: 0 = all clear, 1 = one or more errors */ 'use strict'; const fs = require('fs'); const path = require('path'); // ─── Config ────────────────────────────────────────────────────────────────── const SKILLS_DIR = path.resolve(__dirname, '..', 'skills'); const MAX_DESCRIPTION_LENGTH = 1024; // A skill directory name must be lowercase-hyphen-separated // (docs/skill-anatomy.md → Naming Conventions). const KEBAB_CASE = /^[a-z0-9]+(-[a-z0-9]+)*$/; // A description must state WHEN to use the skill, not just what it does // (docs/skill-anatomy.md → Required). Accept the canonical "Use when …" // plus the equivalent "Use before/after/during …" phrasings in use today. const DESCRIPTION_TRIGGER = /\buse (this )?when\b|\buse (before|after|during)\b/i; // Sections every standard SKILL.md must contain. // Each entry is an array of acceptable heading strings — the first // match wins, so you can list canonical + legacy aliases. const REQUIRED_SECTIONS = [ ['## Overview'], ['## When to Use'], ['## Common Rationalizations'], ['## Red Flags'], ['## Verification'], ]; // Skills that are intentionally exempt from section checks. // Exemptions live HERE, not in skill frontmatter, so contributors // cannot bypass the validator by editing their own skill file. // Every entry must have a documented reason. const SECTION_EXEMPT_SKILLS = { 'using-agent-skills': 'Meta-skill — orchestrates other skills; When-to-Use and Verification are not applicable to a routing document.', 'idea-refine': 'Legacy structure predating skill-anatomy.md — uses How-It-Works/Usage/Anti-patterns instead of standard headings. Tracked for conformance in https://github.com/addyosmani/agent-skills/issues', }; // Regex patterns that indicate an explicit cross-skill reference. // Only these patterns trigger the dead-reference warning — generic // backtick strings in code blocks are intentionally excluded. const SKILL_REF_PATTERNS = [ /\buse the `([a-z][a-z0-9-]+[a-z0-9])` skill/g, /\bfollow the `([a-z][a-z0-9-]+[a-z0-9])` skill/g, /\binvoke the `([a-z][a-z0-9-]+[a-z0-9])` skill/g, /\bcontinue with `([a-z][a-z0-9-]+[a-z0-9])`/g, /\buse `([a-z][a-z0-9-]+[a-z0-9])` skill/g, /`([a-z][a-z0-9-]+[a-z0-9])` skill\b/g, /`([a-z][a-z0-9-]+[a-z0-9])` persona\b/g, /\bsee `([a-z][a-z0-9-]+[a-z0-9])`/g, /──→ ([a-z][a-z0-9-]+[a-z0-9])\b/g, // ASCII diagram arrows /→ `([a-z][a-z0-9-]+[a-z0-9])`/g, ]; // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Parse YAML-style frontmatter from the top of a markdown file. * Returns a key→value object, or null if no frontmatter block found. * Values are stripped of surrounding quotes. */ function parseFrontmatter(content) { const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/); if (!match) return null; const result = {}; for (const line of match[1].split(/\r?\n/)) { const colonIdx = line.indexOf(':'); if (colonIdx === -1) continue; const key = line.slice(0, colonIdx).trim(); const value = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, ''); if (key) result[key] = value; } return result; } /** * Collect all explicit skill cross-references from content. * Only matches against the SKILL_REF_PATTERNS list to avoid * false-positives from inline code snippets. */ function extractSkillReferences(content) { const refs = new Set(); for (const pattern of SKILL_REF_PATTERNS) { // Reset lastIndex for global regexes pattern.lastIndex = 0; let m; while ((m = pattern.exec(content)) !== null) { refs.add(m[1]); } } return refs; } // ─── Validator ─────────────────────────────────────────────────────────────── function validateSkill(dirName, knownSkills) { const errors = []; const warnings = []; let exempt = false; const skillPath = path.join(SKILLS_DIR, dirName, 'SKILL.md'); if (!fs.existsSync(skillPath)) { errors.push('Missing SKILL.md'); return { errors, warnings, exempt }; } let content; try { content = fs.readFileSync(skillPath, 'utf8'); } catch (err) { errors.push(`Unreadable SKILL.md: ${err.message}`); return { errors, warnings, exempt }; } // ── Frontmatter ────────────────────────────────────────────────────────── const fm = parseFrontmatter(content); if (!fm) { errors.push('Missing or malformed YAML frontmatter (expected --- block at top of file)'); return { errors, warnings, exempt }; } if (!fm.name) { errors.push("Frontmatter missing required field: 'name'"); } else if (fm.name !== dirName) { errors.push(`Frontmatter name '${fm.name}' does not match directory name '${dirName}'`); } if (!KEBAB_CASE.test(dirName)) { errors.push(`Directory name '${dirName}' is not lowercase-hyphen-separated (skill-anatomy.md: Naming Conventions)`); } if (!fm.description) { errors.push("Frontmatter missing required field: 'description'"); } else { if (fm.description.length > MAX_DESCRIPTION_LENGTH) { errors.push( `Description is ${fm.description.length} chars — exceeds the ${MAX_DESCRIPTION_LENGTH}-char limit` + ` (agents inject this into the system prompt)` ); } if (!DESCRIPTION_TRIGGER.test(fm.description)) { errors.push( `Description has no 'when to use' trigger — add a "Use when …" clause ` + `(skill-anatomy.md: Required — the description must say both what the skill does and when to use it)` ); } } // ── Exemption guard ────────────────────────────────────────────────────── // Exemptions are validator-owned (SECTION_EXEMPT_SKILLS above). // If a skill's frontmatter tries to declare its own exemption, fail loud — // that's a sign someone is trying to bypass the validator. if (fm.type === 'meta' || fm.exempt === 'sections') { if (!SECTION_EXEMPT_SKILLS[dirName]) { errors.push( `Frontmatter declares 'type: meta' or 'exempt: sections' but '${dirName}' is not in ` + `the validator's SECTION_EXEMPT_SKILLS allowlist. ` + `Add an entry to scripts/validate-skills.js with a documented reason.` ); } } // ── Required sections ──────────────────────────────────────────────────── exempt = dirName in SECTION_EXEMPT_SKILLS; if (!exempt) { for (const aliases of REQUIRED_SECTIONS) { const found = aliases.some(heading => content.includes(heading)); if (!found) { errors.push(`Missing required section: ${aliases[0]}`); } } } // ── Cross-skill references ─────────────────────────────────────────────── const refs = extractSkillReferences(content); for (const ref of refs) { if (!knownSkills.has(ref)) { warnings.push(`Dead cross-reference: \`${ref}\` is not a known skill`); } } return { errors, warnings, exempt }; } // ─── Main ──────────────────────────────────────────────────────────────────── function main() { if (!fs.existsSync(SKILLS_DIR)) { console.error(`ERROR: skills directory not found at ${SKILLS_DIR}`); process.exit(1); } const skillDirs = fs.readdirSync(SKILLS_DIR) .filter(d => fs.statSync(path.join(SKILLS_DIR, d)).isDirectory()) .sort(); const knownSkills = new Set(skillDirs); let totalErrors = 0; let totalWarnings = 0; for (const dirName of skillDirs) { const { errors, warnings, exempt } = validateSkill(dirName, knownSkills); totalErrors += errors.length; totalWarnings += warnings.length; if (errors.length === 0 && warnings.length === 0) { const tag = exempt ? ' (section checks exempt)' : ''; console.log(` ✓ ${dirName}${tag}`); } else { const icon = errors.length > 0 ? ' ✗ ' : ' ⚠ '; console.log(`${icon} ${dirName}`); for (const msg of errors) console.log(` ERROR: ${msg}`); for (const msg of warnings) console.log(` WARN: ${msg}`); } } const status = totalErrors > 0 ? 'FAILED' : totalWarnings > 0 ? 'PASSED WITH WARNINGS' : 'PASSED'; console.log(`\n${skillDirs.length} skills checked — ${totalErrors} error(s), ${totalWarnings} warning(s) — ${status}`); if (totalErrors > 0) process.exit(1); } // Surface unexpected failures (fs errors, bad symlinks, …) as a structured // one-line CI error instead of an uncaught stack trace. try { main(); } catch (err) { console.error(`\nERROR: validate-skills failed unexpectedly: ${err.message}`); process.exit(1); }