|
| 1 | +#!/usr/bin/env node |
| 2 | +// |
| 3 | +// Structural validator for skill contributions. |
| 4 | +// |
| 5 | +// Reuses the installer's own parser (bin/metamask-skills.mjs collectSkills / |
| 6 | +// parseFrontmatter) so that it validates exactly what ships, rather than a |
| 7 | +// parallel model. Errors block; warnings advise. Exits non-zero on any error. |
| 8 | +// |
| 9 | +// Run against the repo: node .github/scripts/lint-skill-entry.mjs |
| 10 | +// Run against another tree: SKILLS_LINT_ROOT=/path node .github/scripts/lint-skill-entry.mjs |
| 11 | + |
| 12 | +import { readFileSync, readdirSync } from 'node:fs'; |
| 13 | +import path from 'node:path'; |
| 14 | +import { fileURLToPath } from 'node:url'; |
| 15 | + |
| 16 | +import { collectSkills, parseFrontmatter } from '../../bin/metamask-skills.mjs'; |
| 17 | +import { |
| 18 | + ALLOWED_SIBLING_DIRS, |
| 19 | + DESCRIPTION_MAX, |
| 20 | + INSTALLED_PREFIX, |
| 21 | + KNOWN_FRONTMATTER, |
| 22 | + KNOWN_REPOS, |
| 23 | + MATURITY_VALUES, |
| 24 | + NAME_PATTERN, |
| 25 | + RECOMMENDED_SECTIONS, |
| 26 | +} from '../../tools/skill-schema.mjs'; |
| 27 | + |
| 28 | +const ROOT = process.env.SKILLS_LINT_ROOT |
| 29 | + ? path.resolve(process.env.SKILLS_LINT_ROOT) |
| 30 | + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); |
| 31 | + |
| 32 | +const allowedSiblings = new Set(ALLOWED_SIBLING_DIRS); |
| 33 | +const TRUTHY = new Set(['1', 'true', 'yes', 'on']); |
| 34 | + |
| 35 | +export function lintSkill(skill) { |
| 36 | + const errors = []; |
| 37 | + const warnings = []; |
| 38 | + const dirName = skill.id.slice(skill.domain.length + 1); |
| 39 | + |
| 40 | + let raw; |
| 41 | + try { |
| 42 | + raw = parseFrontmatter(readFileSync(path.join(skill.path, 'skill.md'), 'utf8')); |
| 43 | + } catch (error) { |
| 44 | + return { errors: [`could not read skill.md: ${error.message}`], warnings }; |
| 45 | + } |
| 46 | + |
| 47 | + if (!raw.name) { |
| 48 | + errors.push('missing required `name` in frontmatter'); |
| 49 | + } else { |
| 50 | + if (raw.name !== dirName) { |
| 51 | + errors.push(`\`name\` "${raw.name}" must match the directory "${dirName}"`); |
| 52 | + } |
| 53 | + if (!NAME_PATTERN.test(raw.name)) { |
| 54 | + errors.push(`\`name\` "${raw.name}" must be kebab-case`); |
| 55 | + } |
| 56 | + if (raw.name.startsWith(INSTALLED_PREFIX)) { |
| 57 | + errors.push(`source \`name\` must not carry the \`${INSTALLED_PREFIX}\` prefix; the installer adds it`); |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + if (!raw.description) { |
| 62 | + errors.push('missing required `description` in frontmatter'); |
| 63 | + } else if (raw.description.length > DESCRIPTION_MAX) { |
| 64 | + errors.push(`\`description\` is ${raw.description.length} chars, over the ${DESCRIPTION_MAX}-char operator ceiling`); |
| 65 | + } |
| 66 | + |
| 67 | + if (raw.maturity && !MATURITY_VALUES.includes(raw.maturity)) { |
| 68 | + errors.push(`\`maturity\` "${raw.maturity}" must be one of: ${MATURITY_VALUES.join(', ')}`); |
| 69 | + } |
| 70 | + |
| 71 | + // On-demand-only contract: a source skill must not force persistent loading. |
| 72 | + if (raw.alwaysApply !== undefined && TRUTHY.has(String(raw.alwaysApply).toLowerCase())) { |
| 73 | + errors.push('skills are on-demand only; remove `alwaysApply: true` (always-on guidance belongs in AGENTS.md)'); |
| 74 | + } |
| 75 | + |
| 76 | + // Sibling directories: bundle dirs and repos/ only. knowledge/ is rejected. |
| 77 | + let entries = []; |
| 78 | + try { |
| 79 | + entries = readdirSync(skill.path, { withFileTypes: true }); |
| 80 | + } catch { |
| 81 | + // skill dir vanished mid-run; nothing to check |
| 82 | + } |
| 83 | + for (const entry of entries) { |
| 84 | + if (entry.isDirectory() && !allowedSiblings.has(entry.name)) { |
| 85 | + errors.push(`unexpected directory "${entry.name}/" beside skill.md (allowed: ${[...allowedSiblings].join(', ')}); domain knowledge belongs in references/`); |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + for (const repo of skill.repos) { |
| 90 | + if (!KNOWN_REPOS.includes(repo)) { |
| 91 | + warnings.push(`repos/${repo}.md targets an unknown consumer (known: ${KNOWN_REPOS.join(', ')})`); |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + for (const key of Object.keys(raw)) { |
| 96 | + if (!KNOWN_FRONTMATTER.includes(key) && key !== 'alwaysApply') { |
| 97 | + warnings.push(`unknown frontmatter key "${key}"; operators silently ignore unrecognised keys (typo?)`); |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + for (const section of RECOMMENDED_SECTIONS) { |
| 102 | + if (!new RegExp(`^#{1,4}\\s+${section}\\b`, 'imu').test(skill.body)) { |
| 103 | + warnings.push(`missing recommended section "## ${section}"`); |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + return { errors, warnings }; |
| 108 | +} |
| 109 | + |
| 110 | +// Restrict to skills touched by the given file paths (the CI gate passes the |
| 111 | +// PR's changed files, so pre-existing drift in untouched skills never blocks an |
| 112 | +// unrelated change). With no paths, every skill is linted (a full audit). |
| 113 | +function skillsForPaths(skills, paths) { |
| 114 | + const resolved = paths.map((file) => path.resolve(ROOT, file)); |
| 115 | + return skills.filter((skill) => |
| 116 | + resolved.some((file) => file === skill.path || file.startsWith(`${skill.path}${path.sep}`)), |
| 117 | + ); |
| 118 | +} |
| 119 | + |
| 120 | +function main() { |
| 121 | + const paths = process.argv.slice(2).filter((arg) => !arg.startsWith('-')); |
| 122 | + const all = collectSkills([ROOT]); |
| 123 | + const skills = paths.length > 0 ? skillsForPaths(all, paths) : all; |
| 124 | + let errorCount = 0; |
| 125 | + let warningCount = 0; |
| 126 | + |
| 127 | + for (const skill of skills) { |
| 128 | + const { errors, warnings } = lintSkill(skill); |
| 129 | + if (errors.length > 0 || warnings.length > 0) { |
| 130 | + console.log(`\n${skill.id}`); |
| 131 | + for (const message of errors) { |
| 132 | + console.log(` error: ${message}`); |
| 133 | + } |
| 134 | + for (const message of warnings) { |
| 135 | + console.log(` warning: ${message}`); |
| 136 | + } |
| 137 | + } |
| 138 | + errorCount += errors.length; |
| 139 | + warningCount += warnings.length; |
| 140 | + } |
| 141 | + |
| 142 | + console.log(`\n${skills.length} skill(s) checked, ${errorCount} error(s), ${warningCount} warning(s).`); |
| 143 | + // Set exitCode rather than process.exit() so buffered stdout flushes when it |
| 144 | + // is a pipe (e.g. under CI or execFileSync), instead of being truncated. |
| 145 | + process.exitCode = errorCount > 0 ? 1 : 0; |
| 146 | +} |
| 147 | + |
| 148 | +const invokedDirectly = |
| 149 | + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); |
| 150 | +if (invokedDirectly) { |
| 151 | + main(); |
| 152 | +} |
0 commit comments