From 3d6c75d37fe47c68accb622f13900e9635e830b8 Mon Sep 17 00:00:00 2001 From: sck_0 Date: Mon, 2 Feb 2026 21:37:05 +0100 Subject: [PATCH] test: align js validator use-section rules --- .../tests/validate_skills_headings.test.js | 16 + scripts/validate-skills.js | 299 ++++++++++-------- 2 files changed, 177 insertions(+), 138 deletions(-) create mode 100644 scripts/tests/validate_skills_headings.test.js diff --git a/scripts/tests/validate_skills_headings.test.js b/scripts/tests/validate_skills_headings.test.js new file mode 100644 index 00000000..e980cd7d --- /dev/null +++ b/scripts/tests/validate_skills_headings.test.js @@ -0,0 +1,16 @@ +const assert = require('assert'); +const { hasUseSection } = require('../validate-skills'); + +const samples = [ + ['## When to Use', true], + ['## Use this skill when', true], + ['## When to Use This Skill', true], + ['## Overview', false], +]; + +for (const [heading, expected] of samples) { + const content = `\n${heading}\n- item\n`; + assert.strictEqual(hasUseSection(content), expected, heading); +} + +console.log('ok'); diff --git a/scripts/validate-skills.js b/scripts/validate-skills.js index 48111211..77318324 100644 --- a/scripts/validate-skills.js +++ b/scripts/validate-skills.js @@ -32,12 +32,24 @@ const MAX_SKILL_LINES = 500; const ALLOWED_FIELDS = new Set([ 'name', 'description', + 'risk', + 'source', 'license', 'compatibility', 'metadata', 'allowed-tools', ]); +const USE_SECTION_PATTERNS = [ + /^##\s+When\s+to\s+Use/im, + /^##\s+Use\s+this\s+skill\s+when/im, + /^##\s+When\s+to\s+Use\s+This\s+Skill/im, +]; + +function hasUseSection(content) { + return USE_SECTION_PATTERNS.some(pattern => pattern.test(content)); +} + function isPlainObject(value) { return value && typeof value === 'object' && !Array.isArray(value); } @@ -99,172 +111,183 @@ function addStrictSectionErrors(label, missing, baselineSet) { } } -const skillIds = listSkillIds(SKILLS_DIR); -const baseline = loadBaseline(); -const baselineUse = new Set(baseline.useSection || []); -const baselineDoNotUse = new Set(baseline.doNotUseSection || []); -const baselineInstructions = new Set(baseline.instructionsSection || []); -const baselineLongFile = new Set(baseline.longFile || []); +function run() { + const skillIds = listSkillIds(SKILLS_DIR); + const baseline = loadBaseline(); + const baselineUse = new Set(baseline.useSection || []); + const baselineDoNotUse = new Set(baseline.doNotUseSection || []); + const baselineInstructions = new Set(baseline.instructionsSection || []); + const baselineLongFile = new Set(baseline.longFile || []); -for (const skillId of skillIds) { - const skillPath = path.join(SKILLS_DIR, skillId, 'SKILL.md'); + for (const skillId of skillIds) { + const skillPath = path.join(SKILLS_DIR, skillId, 'SKILL.md'); - if (!fs.existsSync(skillPath)) { - addError(`Missing SKILL.md: ${skillId}`); - continue; - } - - const content = fs.readFileSync(skillPath, 'utf8'); - const { data, errors: fmErrors, hasFrontmatter } = parseFrontmatter(content); - const lineCount = content.split(/\r?\n/).length; - - if (!hasFrontmatter) { - addError(`Missing frontmatter: ${skillId}`); - } - - if (fmErrors && fmErrors.length) { - fmErrors.forEach(error => addError(`Frontmatter parse error (${skillId}): ${error}`)); - } - - if (!NAME_PATTERN.test(skillId)) { - addError(`Folder name must match ${NAME_PATTERN}: ${skillId}`); - } - - if (data.name !== undefined) { - const nameError = validateStringField('name', data.name, { min: 1, max: MAX_NAME_LENGTH }); - if (nameError) { - addError(`${nameError} (${skillId})`); - } else { - const nameValue = String(data.name).trim(); - if (!NAME_PATTERN.test(nameValue)) { - addError(`name must match ${NAME_PATTERN}: ${skillId}`); - } - if (nameValue !== skillId) { - addError(`name must match folder name: ${skillId} -> ${nameValue}`); - } + if (!fs.existsSync(skillPath)) { + addError(`Missing SKILL.md: ${skillId}`); + continue; } - } - const descError = data.description === undefined - ? 'description is required.' - : validateStringField('description', data.description, { min: 1, max: MAX_DESCRIPTION_LENGTH }); - if (descError) { - addError(`${descError} (${skillId})`); - } + const content = fs.readFileSync(skillPath, 'utf8'); + const { data, errors: fmErrors, hasFrontmatter } = parseFrontmatter(content); + const lineCount = content.split(/\r?\n/).length; - if (data.license !== undefined) { - const licenseError = validateStringField('license', data.license, { min: 1, max: 128 }); - if (licenseError) { - addError(`${licenseError} (${skillId})`); + if (!hasFrontmatter) { + addError(`Missing frontmatter: ${skillId}`); } - } - if (data.compatibility !== undefined) { - const compatibilityError = validateStringField( - 'compatibility', - data.compatibility, - { min: 1, max: MAX_COMPATIBILITY_LENGTH }, - ); - if (compatibilityError) { - addError(`${compatibilityError} (${skillId})`); + if (fmErrors && fmErrors.length) { + fmErrors.forEach(error => addError(`Frontmatter parse error (${skillId}): ${error}`)); } - } - if (data['allowed-tools'] !== undefined) { - if (typeof data['allowed-tools'] !== 'string') { - addError(`allowed-tools must be a space-delimited string. (${skillId})`); - } else if (!data['allowed-tools'].trim()) { - addError(`allowed-tools cannot be empty. (${skillId})`); + if (!NAME_PATTERN.test(skillId)) { + addError(`Folder name must match ${NAME_PATTERN}: ${skillId}`); } - } - if (data.metadata !== undefined) { - if (!isPlainObject(data.metadata)) { - addError(`metadata must be a string map/object. (${skillId})`); - } else { - for (const [key, value] of Object.entries(data.metadata)) { - if (typeof value !== 'string') { - addError(`metadata.${key} must be a string. (${skillId})`); + if (data.name !== undefined) { + const nameError = validateStringField('name', data.name, { min: 1, max: MAX_NAME_LENGTH }); + if (nameError) { + addError(`${nameError} (${skillId})`); + } else { + const nameValue = String(data.name).trim(); + if (!NAME_PATTERN.test(nameValue)) { + addError(`name must match ${NAME_PATTERN}: ${skillId}`); + } + if (nameValue !== skillId) { + addError(`name must match folder name: ${skillId} -> ${nameValue}`); } } } - } - if (data && Object.keys(data).length) { - const unknownFields = Object.keys(data).filter(key => !ALLOWED_FIELDS.has(key)); - if (unknownFields.length) { - unknownFieldSkills.push(skillId); - addError(`Unknown frontmatter fields (${skillId}): ${unknownFields.join(', ')}`); + const descError = data.description === undefined + ? 'description is required.' + : validateStringField('description', data.description, { min: 1, max: MAX_DESCRIPTION_LENGTH }); + if (descError) { + addError(`${descError} (${skillId})`); + } + + if (data.license !== undefined) { + const licenseError = validateStringField('license', data.license, { min: 1, max: 128 }); + if (licenseError) { + addError(`${licenseError} (${skillId})`); + } + } + + if (data.compatibility !== undefined) { + const compatibilityError = validateStringField( + 'compatibility', + data.compatibility, + { min: 1, max: MAX_COMPATIBILITY_LENGTH }, + ); + if (compatibilityError) { + addError(`${compatibilityError} (${skillId})`); + } + } + + if (data['allowed-tools'] !== undefined) { + if (typeof data['allowed-tools'] !== 'string') { + addError(`allowed-tools must be a space-delimited string. (${skillId})`); + } else if (!data['allowed-tools'].trim()) { + addError(`allowed-tools cannot be empty. (${skillId})`); + } + } + + if (data.metadata !== undefined) { + if (!isPlainObject(data.metadata)) { + addError(`metadata must be a string map/object. (${skillId})`); + } else { + for (const [key, value] of Object.entries(data.metadata)) { + if (typeof value !== 'string') { + addError(`metadata.${key} must be a string. (${skillId})`); + } + } + } + } + + if (data && Object.keys(data).length) { + const unknownFields = Object.keys(data).filter(key => !ALLOWED_FIELDS.has(key)); + if (unknownFields.length) { + unknownFieldSkills.push(skillId); + addError(`Unknown frontmatter fields (${skillId}): ${unknownFields.join(', ')}`); + } + } + + if (lineCount > MAX_SKILL_LINES) { + longFiles.push(skillId); + } + + if (!hasUseSection(content)) { + missingUseSection.push(skillId); + } + + if (!content.includes('## Do not use')) { + missingDoNotUseSection.push(skillId); + } + + if (!content.includes('## Instructions')) { + missingInstructionsSection.push(skillId); } } - if (lineCount > MAX_SKILL_LINES) { - longFiles.push(skillId); + if (missingUseSection.length) { + addWarning(`Missing "Use this skill when" section: ${missingUseSection.length} skills (examples: ${missingUseSection.slice(0, 5).join(', ')})`); } - if (!content.includes('## Use this skill when')) { - missingUseSection.push(skillId); + if (missingDoNotUseSection.length) { + addWarning(`Missing "Do not use" section: ${missingDoNotUseSection.length} skills (examples: ${missingDoNotUseSection.slice(0, 5).join(', ')})`); } - if (!content.includes('## Do not use')) { - missingDoNotUseSection.push(skillId); + if (missingInstructionsSection.length) { + addWarning(`Missing "Instructions" section: ${missingInstructionsSection.length} skills (examples: ${missingInstructionsSection.slice(0, 5).join(', ')})`); } - if (!content.includes('## Instructions')) { - missingInstructionsSection.push(skillId); + if (longFiles.length) { + addWarning(`SKILL.md over ${MAX_SKILL_LINES} lines: ${longFiles.length} skills (examples: ${longFiles.slice(0, 5).join(', ')})`); } -} -if (missingUseSection.length) { - addWarning(`Missing "Use this skill when" section: ${missingUseSection.length} skills (examples: ${missingUseSection.slice(0, 5).join(', ')})`); -} - -if (missingDoNotUseSection.length) { - addWarning(`Missing "Do not use" section: ${missingDoNotUseSection.length} skills (examples: ${missingDoNotUseSection.slice(0, 5).join(', ')})`); -} - -if (missingInstructionsSection.length) { - addWarning(`Missing "Instructions" section: ${missingInstructionsSection.length} skills (examples: ${missingInstructionsSection.slice(0, 5).join(', ')})`); -} - -if (longFiles.length) { - addWarning(`SKILL.md over ${MAX_SKILL_LINES} lines: ${longFiles.length} skills (examples: ${longFiles.slice(0, 5).join(', ')})`); -} - -if (unknownFieldSkills.length) { - addWarning(`Unknown frontmatter fields detected: ${unknownFieldSkills.length} skills (examples: ${unknownFieldSkills.slice(0, 5).join(', ')})`); -} - -addStrictSectionErrors('Use this skill when', missingUseSection, baselineUse); -addStrictSectionErrors('Do not use', missingDoNotUseSection, baselineDoNotUse); -addStrictSectionErrors('Instructions', missingInstructionsSection, baselineInstructions); -addStrictSectionErrors(`SKILL.md line count <= ${MAX_SKILL_LINES}`, longFiles, baselineLongFile); - -if (writeBaseline) { - const baselineData = { - generatedAt: new Date().toISOString(), - useSection: [...missingUseSection].sort(), - doNotUseSection: [...missingDoNotUseSection].sort(), - instructionsSection: [...missingInstructionsSection].sort(), - longFile: [...longFiles].sort(), - }; - fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineData, null, 2)); - console.log(`Baseline written to ${BASELINE_PATH}`); -} - -if (warnings.length) { - console.warn('Warnings:'); - for (const warning of warnings) { - console.warn(`- ${warning}`); + if (unknownFieldSkills.length) { + addWarning(`Unknown frontmatter fields detected: ${unknownFieldSkills.length} skills (examples: ${unknownFieldSkills.slice(0, 5).join(', ')})`); } -} -if (errors.length) { - console.error('\nErrors:'); - for (const error of errors) { - console.error(`- ${error}`); + addStrictSectionErrors('Use this skill when', missingUseSection, baselineUse); + addStrictSectionErrors('Do not use', missingDoNotUseSection, baselineDoNotUse); + addStrictSectionErrors('Instructions', missingInstructionsSection, baselineInstructions); + addStrictSectionErrors(`SKILL.md line count <= ${MAX_SKILL_LINES}`, longFiles, baselineLongFile); + + if (writeBaseline) { + const baselineData = { + generatedAt: new Date().toISOString(), + useSection: [...missingUseSection].sort(), + doNotUseSection: [...missingDoNotUseSection].sort(), + instructionsSection: [...missingInstructionsSection].sort(), + longFile: [...longFiles].sort(), + }; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineData, null, 2)); + console.log(`Baseline written to ${BASELINE_PATH}`); } - process.exit(1); + + if (warnings.length) { + console.warn('Warnings:'); + for (const warning of warnings) { + console.warn(`- ${warning}`); + } + } + + if (errors.length) { + console.error('\nErrors:'); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); + } + + console.log(`Validation passed for ${skillIds.length} skills.`); } -console.log(`Validation passed for ${skillIds.length} skills.`); +if (require.main === module) { + run(); +} + +module.exports = { + hasUseSection, + run, +};