Files
agent-skills/scripts/lib/skill-lint.js
T

640 lines
27 KiB
JavaScript

'use strict';
/**
* skill-lint.js — the skill validation rules, as a shared library.
*
* This is the single source of truth for what makes a SKILL.md valid
* (docs/skill-anatomy.md). The CLI in scripts/validate-skills.js is a thin
* wrapper over it. Splitting the rules out of the CLI keeps them importable
* and unit-testable without spawning a process or touching the filesystem.
*
* 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
* - a skill's subdirectories are non-empty (skill-anatomy.md: Supporting Files)
* - supporting .md filenames are lowercase-hyphen-separated
*
* Checks (warnings, do not block CI):
* - cross-skill references point to known skills
* - SKILL.md stays under the 500-line context budget
*/
const fs = require('fs');
const path = require('path');
// ─── Config ──────────────────────────────────────────────────────────────────
const MAX_DESCRIPTION_LENGTH = 1024;
// SKILL.md loads in full the moment an agent activates the skill, so its length
// is paid for on every activation (docs/skill-anatomy.md -> Context Efficiency).
// A warning, not an error: trimming a skill is an editorial call, and failing
// CI on line count would block unrelated work on an over-budget skill.
const MAX_SKILL_LINES = 500;
// The only keys the Agent Skills specification allows at the top level of
// SKILL.md frontmatter. Anything else — model hints, tool lists, turn limits,
// host-specific switches — belongs under `metadata` or in a per-agent adapter
// file (docs/advanced-per-agent-configuration.md). Hosts do not promise to
// ignore unknown top-level keys, and a published skill carrying one can fail
// packaging on a strict client, so this is an error rather than a warning.
const SPEC_FRONTMATTER_KEYS = new Set([
'name',
'description',
'license',
'compatibility',
'metadata',
'allowed-tools',
]);
// 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.
// Reject negated forms ("Do not use when …", "Don't use when …") — those
// describe exclusions, not trigger conditions.
const DESCRIPTION_TRIGGER = /\buse (this )?when\b|\buse (before|after|during)\b/i;
const DESCRIPTION_TRIGGER_NEGATE = /\b(do not|don't|never) use (this )?(when|before|after|during)\b/i;
// Same pattern, global — used to strip *every* negated clause before checking
// whether a positive trigger remains. Without /g only the first is removed, so
// a description carrying two or more negated clauses and no positive one would
// still match DESCRIPTION_TRIGGER on the leftovers and pass. Kept separate from
// the non-global form above because a /g regex carries lastIndex across .test().
const DESCRIPTION_TRIGGER_NEGATE_ALL = new RegExp(DESCRIPTION_TRIGGER_NEGATE.source, 'gi');
// 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.',
};
// 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 ─────────────────────────────────────────────────────────────────
/**
* Strip fenced code blocks from markdown content so that headings, references,
* and trigger phrases inside examples or templates are not matched by lint rules.
*
* Scans line by line and follows the CommonMark fence rules rather than a single
* whole-document regex, which only recognised a column-zero backtick fence closed
* by a run of exactly the same length (#437). The forms that regex let through:
*
* - tilde fences (`~~~`)
* - fences indented by one to three spaces (common inside list items)
* - a closing fence longer than its opener
*
* A fence closes only on a run of the *same* marker at least as long as the
* opener; a shorter run, or the other marker, is content. An unterminated fence
* runs to end of file, so everything after it is treated as fenced and any
* required section below it is reported missing rather than silently accepted.
*
* Stripped lines are replaced with empty lines so line numbers are preserved
* for callers that report positions.
*/
function stripFencedCodeBlocks(content) {
const out = [];
let open = null; // { marker: '`' | '~', length: number } while inside a fence
for (const line of content.split(/\r?\n/)) {
if (open) {
const close = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
if (close && close[1][0] === open.marker && close[1].length >= open.length) {
open = null;
}
out.push('');
continue;
}
const start = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
// CommonMark: the info string of a backtick fence may not contain backticks,
// so a line like ````js``` is inline code` is prose, not an opener.
if (start && !(start[1][0] === '`' && start[2].includes('`'))) {
open = { marker: start[1][0], length: start[1].length };
out.push('');
continue;
}
out.push(line);
}
return out.join('\n');
}
/**
* List the top-level keys in the frontmatter block, in file order. Only
* column-zero `key:` lines count: indented lines are children of the key
* above them (e.g. fields under `metadata`), and `- item` lines are list
* entries. Returns [] when there is no frontmatter block.
*/
function topLevelFrontmatterKeys(content) {
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/);
if (!match) return [];
const keys = [];
for (const line of match[1].split(/\r?\n/)) {
const m = line.match(/^([A-Za-z0-9_-]+)\s*:/);
if (m) keys.push(m[1]);
}
return keys;
}
/**
* 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/)) {
if (/^\s*#/.test(line)) continue;
const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
if (!m) continue;
const key = m[1];
const value = m[2].trim().replace(/^['"]|['"]$/g, '');
result[key] = value;
}
return result;
}
/**
* Report frontmatter that this file's own parser accepts but a real YAML
* parser rejects.
*
* `parseFrontmatter` above splits each line on its first colon. That is enough
* to read `name` and `description`, and it is deliberately forgiving — but it
* means a SKILL.md whose frontmatter is not valid YAML passes every check here.
* The hosts that consume these skills do not share that forgiveness: Cursor
* parses the frontmatter as YAML when a skill is attached to a message, and a
* parse failure there fails the whole request with a generic server error and
* takes the rest of the chat's context with it (#494).
*
* So the repo could ship frontmatter that breaks a host while CI stayed green.
* That was verified once by hand, against all 25 skills, in the #494 thread;
* this makes it a check instead of a memory.
*
* Scope is deliberately narrow — the shapes a strict parser rejects and the
* split-on-first-colon parser does not, each confirmed by measurement against
* both PyYAML and ruby's psych rather than read off the spec:
*
* description: Use this: when X unquoted value with a colon-space: YAML
* reads a nested mapping and errors
* \tkey: value tab indentation: invalid YAML whitespace
* description: "unterminated an unclosed quote
* description: `skill` does X a value opening with a YAML indicator
* (` @ % * ,) or a one-line | / > header
*
* It is not a YAML implementation. This repo has no package.json and therefore
* no parser to depend on, so the check stays a small set of rules aimed at the
* classes actually observed to break a host. No current frontmatter uses block
* scalars or multi-line values, and the check assumes that stays true; add a
* rule here rather than loosening one if it changes.
*/
function frontmatterYamlErrors(content) {
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/);
if (!match) return [];
const errors = [];
const lines = match[1].split(/\r?\n/);
lines.forEach((line, i) => {
const lineNo = i + 2; // the opening `---` is line 1
if (!line.trim() || /^\s*#/.test(line)) return;
if (/^[ ]*\t/.test(line)) {
errors.push(
`Frontmatter line ${lineNo} indents with a tab — YAML forbids tabs in indentation, ` +
`so a host that parses this frontmatter rejects the whole file`
);
return;
}
const colonIdx = line.indexOf(':');
if (colonIdx === -1) return;
const value = line.slice(colonIdx + 1).trim();
if (!value) return;
const quote = value[0] === '"' || value[0] === "'" ? value[0] : null;
if (quote) {
// An opening quote must close. `endsWith` is enough here because a
// trailing comment after a quoted scalar does not appear in this repo.
if (value.length < 2 || !value.endsWith(quote)) {
errors.push(
`Frontmatter line ${lineNo} opens a ${quote === '"' ? 'double' : 'single'} quote that never closes — ` +
`a host parsing this frontmatter as YAML rejects the whole file`
);
}
return;
}
// Unquoted scalar. A plain YAML scalar may not BEGIN with certain indicator
// characters. The set below was measured against a real parser rather than
// read off the spec, both with and without a following space, and only the
// characters invalid in *both* forms with no legitimate single-line use are
// rejected — a guard that flags valid frontmatter gets switched off.
//
// Deliberately not rejected, because each parses cleanly: `:` or `-` when NOT
// followed by a space (`:platform`, `-hyphenated`), `#`, `&anchor`, and
// `[`/`{`, which begin valid flow collections — `[a, b]` and `{a: b}` both
// parse, so flagging the opening bracket would reject valid frontmatter.
//
// An earlier version of this comment claimed a leading `,`, `]` or `}` also
// parsed cleanly. Measured against PyYAML and psych, all three are rejected;
// `,` is flagged below. `]` and `}` are left out only because no frontmatter
// has ever started with them — say so and they can be added.
//
// The backtick is the one that bites this repo: descriptions routinely name
// other skills, and "`other-skill` designs things" is a natural way to start.
if (/^[`@%*,]/.test(value)) {
errors.push(
`Frontmatter line ${lineNo} starts an unquoted value with '${value[0]}' — YAML reserves ` +
`that character and cannot begin a plain scalar with it, so a host parsing this ` +
`frontmatter rejects the whole file; wrap the value in quotes`
);
return;
}
// `|` and `>` open a block scalar only when nothing follows them on the line.
// `|` with indented lines under it is valid and stays allowed; `|foo` is not
// a block scalar, it is a plain scalar starting with an indicator, and both
// parsers reject it.
if (/^[|>]\S/.test(value)) {
errors.push(
`Frontmatter line ${lineNo} starts an unquoted value with '${value[0]}' followed by text — ` +
`YAML reads that as a block scalar header, which may not carry content on its own line, ` +
`so a host parsing this frontmatter rejects the whole file; wrap the value in quotes`
);
return;
}
if (/^[-?&](\s|$)/.test(value)) {
errors.push(
`Frontmatter line ${lineNo} starts an unquoted value with '${value[0]} ' — YAML reads ` +
`that as an indicator rather than text and rejects the file; wrap the value in quotes`
);
return;
}
// A colon followed by a space (or ending the line) makes YAML read a nested
// mapping where a plain string was meant.
if (/:(\s|$)/.test(value)) {
errors.push(
`Frontmatter line ${lineNo} has an unquoted value containing ': ' — YAML reads that as a ` +
`nested mapping and rejects the file; wrap the value in quotes`
);
}
});
return errors;
}
/**
* 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;
}
// ─── Linter ──────────────────────────────────────────────────────────────────
/**
* Lint already-read SKILL.md content. Pure: no filesystem access, so the rules
* can be exercised against crafted fixtures in a unit test.
* Returns { errors, warnings, exempt }.
*/
function lintSkillContent(dirName, content, knownSkills) {
const errors = [];
const warnings = [];
let exempt = false;
// ── Frontmatter ──────────────────────────────────────────────────────────
const fm = parseFrontmatter(content);
if (!fm) {
errors.push('Missing or malformed YAML frontmatter (expected --- block at top of file)');
return { errors, warnings, exempt };
}
// The parser above is forgiving by design; the hosts that read this
// frontmatter are not (#494).
errors.push(...frontmatterYamlErrors(content));
// Only the specification's keys may sit at the top level. Vendor and
// runtime fields go under `metadata` or in a per-agent adapter file.
for (const key of topLevelFrontmatterKeys(content)) {
if (!SPEC_FRONTMATTER_KEYS.has(key)) {
errors.push(
`Frontmatter key '${key}' is not an Agent Skills spec field — top level allows only ` +
`${[...SPEC_FRONTMATTER_KEYS].join(', ')}; put vendor or runtime fields under 'metadata' ` +
`or in a per-agent adapter file (docs/advanced-per-agent-configuration.md)`
);
}
}
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)`
);
}
const hasTrigger = DESCRIPTION_TRIGGER.test(fm.description);
const onlyNegated = hasTrigger && DESCRIPTION_TRIGGER_NEGATE.test(fm.description)
&& !fm.description.replace(DESCRIPTION_TRIGGER_NEGATE_ALL, '').match(DESCRIPTION_TRIGGER);
if (!hasTrigger || onlyNegated) {
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 (!Object.hasOwn(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/lib/skill-lint.js with a documented reason.`
);
}
}
// ── Required sections ────────────────────────────────────────────────────
// `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a skill
// directory named `constructor` — which passes the kebab-case check — would
// otherwise resolve to Object.prototype.constructor and be silently exempt
// from every required-section check.
exempt = Object.hasOwn(SECTION_EXEMPT_SKILLS, dirName);
if (!exempt) {
// Strip fenced code blocks so headings inside examples/templates don't
// satisfy the check, and match headings at the start of a line so
// `### Verification` inside a block doesn't satisfy `## Verification`.
const proseContent = stripFencedCodeBlocks(content);
for (const aliases of REQUIRED_SECTIONS) {
const found = aliases.some(heading => {
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped}\\s*$`, 'm').test(proseContent);
});
if (!found) {
errors.push(`Missing required section: ${aliases[0]}`);
}
}
}
// A named workflow that advertises numbered steps must document each step
// before the next level-two section. Otherwise the summary promises a
// process stage that the skill never teaches agents how to perform.
const workflowSections = content.matchAll(
/^## The [^\n]+ Workflow\s*\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/gm
);
for (const match of workflowSections) {
const section = match[1];
const declared = [...section.matchAll(/^\s*(\d+)\.\s+[A-Z][A-Z -]*\s+→/gm)];
if (declared.length < 2) continue;
const documented = new Set(
[...section.matchAll(/^### Step\s+(\d+):/gm)].map(step => step[1])
);
for (const step of declared) {
if (!documented.has(step[1])) {
errors.push(`Workflow declares Step ${step[1]} but has no matching process section`);
}
}
}
// ── Context budget ───────────────────────────────────────────────────────
// Counted as newlines so the number matches what `wc -l` reports, which is
// how a contributor would check this by hand.
const lineCount = (content.match(/\n/g) || []).length;
if (lineCount > MAX_SKILL_LINES) {
warnings.push(
`SKILL.md is ${lineCount} lines, over the ${MAX_SKILL_LINES}-line context budget ` +
`(skill-anatomy.md: Context Efficiency) — move detail into a supporting file`,
);
}
// ── 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 };
}
/** True when dir contains no regular file at any depth. */
function isEffectivelyEmpty(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (!isEffectivelyEmpty(path.join(dir, entry.name))) return false;
} else {
return false;
}
}
return true;
}
/**
* Lint the files that travel with a skill, as opposed to the contents of
* SKILL.md. These rules need the filesystem, so they live here rather than in
* the pure content linter. Returns an array of error strings.
*
* Enforced (docs/skill-anatomy.md):
* - Supporting Files: "do not create an empty `scripts/` directory just to
* mirror other skills". Generalized to any subdirectory — an empty
* `references/` is noise for the same reason.
* - Naming Conventions: "Supporting files: lowercase-hyphen-separated.md".
* Scoped to .md, which is what that line covers; `scripts/` helpers carry
* their own conventions under Script Requirements.
*/
function lintSkillLayout(skillDir) {
const errors = [];
const walk = (dir, relBase) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (isEffectivelyEmpty(abs)) {
errors.push(
`Empty directory \`${rel}/\` — remove it rather than mirroring another skill's layout ` +
`(skill-anatomy.md: Supporting Files)`,
);
} else {
walk(abs, rel);
}
continue;
}
if (rel === 'SKILL.md' || !entry.name.endsWith('.md')) continue;
if (!KEBAB_CASE.test(entry.name.slice(0, -'.md'.length))) {
errors.push(
`Supporting file \`${rel}\` is not lowercase-hyphen-separated ` +
`(skill-anatomy.md: Naming Conventions)`,
);
}
}
};
walk(skillDir, '');
return errors;
}
/**
* Lint a persona file (agents/<name>.md) against the three rules docs/agents.md
* states for them: frontmatter in the same format as a skill, with `name`
* equal to the file stem (the name is the `subagent_type` commands spawn) and
* a `description`; and a closing `## Composition` section. Headings inside
* fenced blocks are ignored, so an example after Composition does not count as
* a later section. Pure: no filesystem access. Returns { errors }.
*/
function lintPersonaContent(stem, content) {
const errors = [];
const fm = parseFrontmatter(content);
if (!fm) {
errors.push('Missing or malformed YAML frontmatter (expected --- block at top of file)');
return { errors };
}
errors.push(...frontmatterYamlErrors(content));
if (!fm.name) {
errors.push("Frontmatter missing required field: 'name'");
} else if (fm.name !== stem) {
errors.push(`Frontmatter name '${fm.name}' does not match file name '${stem}' (the name is the subagent_type commands spawn)`);
}
if (!fm.description) {
errors.push("Frontmatter missing required field: 'description'");
}
const headings = [...stripFencedCodeBlocks(content).matchAll(/^## +(.+?)\s*$/gm)].map(m => m[1]);
const compositionAt = headings.findIndex(h => /^Composition$/i.test(h));
if (compositionAt === -1) {
errors.push('Missing "## Composition" section (docs/agents.md: every persona file ends with a Composition block)');
} else if (compositionAt !== headings.length - 1) {
errors.push(`"## Composition" must be the last section, but "## ${headings[headings.length - 1]}" follows it`);
}
return { errors };
}
/**
* Lint a persona by file name: reads agents/<file>, then delegates to
* lintPersonaContent. Returns { errors }.
*/
function lintPersona(fileName, agentsDir) {
const stem = fileName.replace(/\.md$/, '');
let content;
try {
content = fs.readFileSync(path.join(agentsDir, fileName), 'utf8');
} catch (err) {
return { errors: [`Unreadable persona file: ${err.message}`] };
}
return lintPersonaContent(stem, content);
}
/**
* Lint a skill by directory name: reads its SKILL.md, then delegates to
* lintSkillContent. This is the thin filesystem wrapper the CLI uses.
* Returns { errors, warnings, exempt }.
*/
function lintSkill(dirName, skillsDir, knownSkills) {
const skillDir = path.join(skillsDir, dirName);
const skillPath = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(skillPath)) {
return { errors: ['Missing SKILL.md'], warnings: [], exempt: false };
}
let content;
try {
content = fs.readFileSync(skillPath, 'utf8');
} catch (err) {
return { errors: [`Unreadable SKILL.md: ${err.message}`], warnings: [], exempt: false };
}
const result = lintSkillContent(dirName, content, knownSkills);
result.errors.push(...lintSkillLayout(skillDir));
return result;
}
// Export only functions. The policy collections (REQUIRED_SECTIONS,
// SECTION_EXEMPT_SKILLS, SKILL_REF_PATTERNS, and the regexes) stay private so a
// test or future consumer cannot mutate shared state and change lint results for
// the rest of the process. Exercise the rules through these functions.
module.exports = {
stripFencedCodeBlocks,
parseFrontmatter,
topLevelFrontmatterKeys,
frontmatterYamlErrors,
extractSkillReferences,
lintSkillContent,
lintSkillLayout,
lintSkill,
lintPersonaContent,
lintPersona,
};