From 488c1ee06e83d70a66eaf2246b29ad47785c82ef Mon Sep 17 00:00:00 2001 From: ayobamiseun Date: Sat, 3 Oct 2026 10:48:32 +0100 Subject: [PATCH] feat(validator): lint personas for name, description and Composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smaller version asked for when #610 was closed. docs/agents.md states three rules for agents/.md — the same frontmatter 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 block — and no validator checked them. Add lintPersonaContent to scripts/lib/skill-lint.js, reusing the existing frontmatter parsing and YAML-validity rules, and have validate-skills.js walk agents/*.md after the skills, skipping an index README.md. Headings are read from fence-stripped content so an example block after Composition does not count as a later section. No spawn-reference scanning, no listing-table checks, no new script, no workflow change. All four personas pass: 25 skills and 4 personas checked, 0 errors. --- scripts/lib/skill-lint-test.js | 68 +++++++++++++++++++++++++++++++++- scripts/lib/skill-lint.js | 55 +++++++++++++++++++++++++++ scripts/validate-skills.js | 29 +++++++++++++-- 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/scripts/lib/skill-lint-test.js b/scripts/lib/skill-lint-test.js index ba08cb2a..5f206186 100644 --- a/scripts/lib/skill-lint-test.js +++ b/scripts/lib/skill-lint-test.js @@ -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/.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, []); +}); diff --git a/scripts/lib/skill-lint.js b/scripts/lib/skill-lint.js index 8779c9c0..691f25e2 100644 --- a/scripts/lib/skill-lint.js +++ b/scripts/lib/skill-lint.js @@ -543,6 +543,59 @@ function lintSkillLayout(skillDir) { return errors; } +/** + * Lint a persona file (agents/.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/, 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, }; diff --git a/scripts/validate-skills.js b/scripts/validate-skills.js index b73a431d..d1722a40 100644 --- a/scripts/validate-skills.js +++ b/scripts/validate-skills.js @@ -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/.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); }