mirror of
https://github.com/addyosmani/agent-skills.git
synced 2026-10-26 15:05:04 +00:00
Merge #630: lint personas for name, description and the Composition section
This commit is contained in:
3 files changed
+148
-4
No files matched your search
@@ -9,7 +9,7 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { lintSkillContent, lintSkillLayout, topLevelFrontmatterKeys } = require('./skill-lint.js');
|
||||
const { lintSkillContent, lintSkillLayout, topLevelFrontmatterKeys, lintPersonaContent } = require('./skill-lint.js');
|
||||
|
||||
const KNOWN = new Set(['alpha', 'beta']);
|
||||
|
||||
@@ -648,3 +648,69 @@ test('the spec-key check tolerates CRLF frontmatter', () => {
|
||||
const { errors } = lintSkillContent('alpha', content, KNOWN);
|
||||
assert.equal(errors.filter(e => /key 'temperature'/.test(e)).length, 1);
|
||||
});
|
||||
|
||||
// ─── Personas (agents/<name>.md) ─────────────────────────────────────────────
|
||||
//
|
||||
// docs/agents.md: same frontmatter format as a skill, name equal to the file
|
||||
// stem, and the file ends with a Composition block. Nothing more is checked.
|
||||
|
||||
function personaFile({ name = 'code-reviewer', description = 'Reviews changes. Use before merging.', body } = {}) {
|
||||
const fm = ['---', name === null ? null : `name: ${name}`, description === null ? null : `description: ${description}`, '---']
|
||||
.filter(l => l !== null).join('\n');
|
||||
return `${fm}\n\n${body ?? '# Persona\n\n## Rules\n\nBe precise.\n\n## Composition\n\n- **Invoke via:** `/ship`.\n'}`;
|
||||
}
|
||||
|
||||
test('a conforming persona produces no errors', () => {
|
||||
assert.deepEqual(lintPersonaContent('code-reviewer', personaFile()).errors, []);
|
||||
});
|
||||
|
||||
test('persona: frontmatter name must match the file stem', () => {
|
||||
const { errors } = lintPersonaContent('code-reviewer', personaFile({ name: 'reviewer' }));
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /name 'reviewer' does not match file name 'code-reviewer'/);
|
||||
});
|
||||
|
||||
test('persona: description is required', () => {
|
||||
const { errors } = lintPersonaContent('code-reviewer', personaFile({ description: null }));
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /missing required field: 'description'/);
|
||||
});
|
||||
|
||||
test('persona: a missing frontmatter block is reported and nothing else is attempted', () => {
|
||||
const { errors } = lintPersonaContent('code-reviewer', '# Persona\n\n## Composition\n');
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /Missing or malformed YAML frontmatter/);
|
||||
});
|
||||
|
||||
test('persona: frontmatter a host would reject as YAML is reported', () => {
|
||||
const { errors } = lintPersonaContent('code-reviewer', personaFile({ description: 'Reviews: everything. Use before merging.' }));
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /Frontmatter line/);
|
||||
});
|
||||
|
||||
test('persona: the Composition section is required', () => {
|
||||
const { errors } = lintPersonaContent('code-reviewer', personaFile({ body: '# Persona\n\n## Rules\n\nBe precise.\n' }));
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /Missing "## Composition" section/);
|
||||
});
|
||||
|
||||
test('persona: Composition must be the last section', () => {
|
||||
const { errors } = lintPersonaContent('code-reviewer', personaFile({
|
||||
body: '# Persona\n\n## Composition\n\n- **Invoke via:** `/ship`.\n\n## Output Format\n\nA report.\n',
|
||||
}));
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /must be the last section, but "## Output Format" follows it/);
|
||||
});
|
||||
|
||||
test('persona: a heading inside a fenced example after Composition is not a later section', () => {
|
||||
const body = [
|
||||
'# Persona', '', '## Composition', '', '- **Invoke via:** `/ship`. Report in this shape:', '',
|
||||
'```markdown', '## Review: [title]', 'Findings…', '```', '',
|
||||
].join('\n');
|
||||
assert.deepEqual(lintPersonaContent('code-reviewer', personaFile({ body })).errors, []);
|
||||
});
|
||||
|
||||
test('persona: the Composition heading match is case-insensitive and tolerates trailing spaces', () => {
|
||||
const body = '# Persona\n\n## composition \n\n- **Invoke via:** `/ship`.\n';
|
||||
assert.deepEqual(lintPersonaContent('code-reviewer', personaFile({ body })).errors, []);
|
||||
});
|
||||
@@ -543,6 +543,59 @@ function lintSkillLayout(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.
|
||||
@@ -581,4 +634,6 @@ module.exports = {
|
||||
lintSkillContent,
|
||||
lintSkillLayout,
|
||||
lintSkill,
|
||||
lintPersonaContent,
|
||||
lintPersona,
|
||||
};
|
||||
@@ -3,7 +3,8 @@
|
||||
* validate-skills.js
|
||||
*
|
||||
* CLI that validates every skill in skills/ against the rules in
|
||||
* docs/skill-anatomy.md. The rules themselves live in scripts/lib/skill-lint.js
|
||||
* docs/skill-anatomy.md, and every persona in agents/ against the three rules
|
||||
* in docs/agents.md. The rules themselves live in scripts/lib/skill-lint.js
|
||||
* (a single source of truth, importable and unit-testable); this file is a thin
|
||||
* wrapper that walks the skills directory, runs the linter, prints the report,
|
||||
* and sets the exit code.
|
||||
@@ -16,9 +17,10 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { lintSkill } = require('./lib/skill-lint');
|
||||
const { lintSkill, lintPersona } = require('./lib/skill-lint');
|
||||
|
||||
const SKILLS_DIR = path.resolve(__dirname, '..', 'skills');
|
||||
const AGENTS_DIR = path.resolve(__dirname, '..', 'agents');
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -53,8 +55,29 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Personas ─────────────────────────────────────────────────────────────
|
||||
// agents/<name>.md files are consumed as subagents and spawned by name from
|
||||
// commands, so they get the same frontmatter contract plus a closing
|
||||
// Composition section (docs/agents.md). An index file such as README.md is
|
||||
// not a persona and is skipped.
|
||||
const personaFiles = fs.existsSync(AGENTS_DIR)
|
||||
? fs.readdirSync(AGENTS_DIR).filter(f => f.endsWith('.md') && !/^readme\.md$/i.test(f)).sort()
|
||||
: [];
|
||||
|
||||
if (personaFiles.length > 0) console.log('');
|
||||
for (const fileName of personaFiles) {
|
||||
const { errors } = lintPersona(fileName, AGENTS_DIR);
|
||||
totalErrors += errors.length;
|
||||
if (errors.length === 0) {
|
||||
console.log(` ✓ agents/${fileName}`);
|
||||
} else {
|
||||
console.log(` ✗ agents/${fileName}`);
|
||||
for (const msg of errors) console.log(` ERROR: ${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}`);
|
||||
console.log(`\n${skillDirs.length} skills and ${personaFiles.length} personas checked — ${totalErrors} error(s), ${totalWarnings} warning(s) — ${status}`);
|
||||
|
||||
if (totalErrors > 0) process.exit(1);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user