mirror of
https://github.com/duthaho/claudekit.git
synced 2026-07-14 19:55:18 +03:00
feat: add validate-plugin script linting SKILL.md anatomy + version sync
Checks every skills/*/SKILL.md for required frontmatter (name matching directory, description, user-invocable) and the 8-section anatomy from README (init is anatomy-exempt as the documented off-spine skill), plus plugin.json/marketplace.json version sync. Zero deps, exits 1 on any violation.
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Repo validator: lints every skills/<name>/SKILL.md against the 8-section
|
||||||
|
* anatomy documented in README.md ("Skill anatomy"), and checks that the
|
||||||
|
* plugin version is in sync between .claude-plugin/plugin.json and
|
||||||
|
* .claude-plugin/marketplace.json.
|
||||||
|
*
|
||||||
|
* CI-runnable, zero dependencies. Exits 1 on any violation (fail loud —
|
||||||
|
* unlike the hook scripts, this is a gate, not a convenience).
|
||||||
|
*
|
||||||
|
* Usage: node scripts/validate-plugin.cjs [repo-root]
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const ROOT = path.resolve(process.argv[2] || path.join(__dirname, ".."));
|
||||||
|
|
||||||
|
// Skills exempt from the 8-section anatomy (off-spine setup skills — see
|
||||||
|
// README.md "The 5-phase spine"). Frontmatter checks still apply.
|
||||||
|
const ANATOMY_EXEMPT = new Set(["init"]);
|
||||||
|
|
||||||
|
// Required ## sections, in relative order (extra sections are allowed).
|
||||||
|
const REQUIRED_SECTIONS = [
|
||||||
|
"Overview",
|
||||||
|
"When to Use",
|
||||||
|
"When NOT to Use",
|
||||||
|
"Process",
|
||||||
|
"Rationalizations",
|
||||||
|
"Evidence Requirements",
|
||||||
|
"Red Flags",
|
||||||
|
"References",
|
||||||
|
];
|
||||||
|
|
||||||
|
const REQUIRED_FRONTMATTER = ["name", "description", "user-invocable"];
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const fail = (file, msg) => errors.push(`${file}: ${msg}`);
|
||||||
|
|
||||||
|
// --- Skill checks ---------------------------------------------------------
|
||||||
|
|
||||||
|
function parseFrontmatter(file, text) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
if (lines[0].trim() !== "---") {
|
||||||
|
fail(file, "missing YAML frontmatter (file must start with ---)");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const end = lines.indexOf("---", 1);
|
||||||
|
if (end === -1) {
|
||||||
|
fail(file, "unterminated YAML frontmatter");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const keys = {};
|
||||||
|
for (const line of lines.slice(1, end)) {
|
||||||
|
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(.*)$/); // top-level keys only
|
||||||
|
if (m) keys[m[1]] = m[2].trim();
|
||||||
|
}
|
||||||
|
return { keys, body: lines.slice(end + 1).join("\n") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSkill(dirName, file, text) {
|
||||||
|
const parsed = parseFrontmatter(file, text);
|
||||||
|
if (!parsed) return;
|
||||||
|
const { keys, body } = parsed;
|
||||||
|
|
||||||
|
for (const key of REQUIRED_FRONTMATTER) {
|
||||||
|
if (!(key in keys)) fail(file, `frontmatter missing required key "${key}"`);
|
||||||
|
}
|
||||||
|
if (keys.name && keys.name !== dirName) {
|
||||||
|
fail(file, `frontmatter name "${keys.name}" does not match directory name "${dirName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ANATOMY_EXEMPT.has(dirName)) return;
|
||||||
|
|
||||||
|
const sections = [...body.matchAll(/^## (.+?)\s*$/gm)].map((m) => m[1]);
|
||||||
|
|
||||||
|
// Required sections must all be present, in relative order.
|
||||||
|
let cursor = 0;
|
||||||
|
for (const required of REQUIRED_SECTIONS) {
|
||||||
|
const idx = sections.indexOf(required, cursor);
|
||||||
|
if (idx === -1) {
|
||||||
|
if (sections.includes(required)) {
|
||||||
|
fail(file, `section "## ${required}" is out of order (expected after "${REQUIRED_SECTIONS[REQUIRED_SECTIONS.indexOf(required) - 1] || "start"}")`);
|
||||||
|
} else {
|
||||||
|
fail(file, `missing required section "## ${required}"`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cursor = idx + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rationalizations must contain a markdown table (README requires a table
|
||||||
|
// of excuses with rebuttals).
|
||||||
|
const ration = body.split(/^## Rationalizations\s*$/m)[1];
|
||||||
|
if (ration !== undefined) {
|
||||||
|
const sectionBody = ration.split(/^## /m)[0];
|
||||||
|
if (!/^\s*\|.+\|\s*$/m.test(sectionBody)) {
|
||||||
|
fail(file, 'section "## Rationalizations" contains no markdown table');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Version sync check ---------------------------------------------------
|
||||||
|
|
||||||
|
function checkVersionSync() {
|
||||||
|
const pluginPath = path.join(ROOT, ".claude-plugin", "plugin.json");
|
||||||
|
const marketPath = path.join(ROOT, ".claude-plugin", "marketplace.json");
|
||||||
|
let plugin, market;
|
||||||
|
try {
|
||||||
|
plugin = JSON.parse(fs.readFileSync(pluginPath, "utf8"));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(".claude-plugin/plugin.json", `unreadable or invalid JSON (${e.message})`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
market = JSON.parse(fs.readFileSync(marketPath, "utf8"));
|
||||||
|
} catch (e) {
|
||||||
|
return fail(".claude-plugin/marketplace.json", `unreadable or invalid JSON (${e.message})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = (market.plugins || []).find((p) => p.name === plugin.name);
|
||||||
|
if (!entry) {
|
||||||
|
return fail(
|
||||||
|
".claude-plugin/marketplace.json",
|
||||||
|
`no plugins[] entry named "${plugin.name}" (plugin.json name)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.version !== plugin.version) {
|
||||||
|
fail(
|
||||||
|
".claude-plugin/marketplace.json",
|
||||||
|
`version "${entry.version}" out of sync with plugin.json version "${plugin.version}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Run ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const skillsDir = path.join(ROOT, "skills");
|
||||||
|
let skillCount = 0;
|
||||||
|
|
||||||
|
for (const dirName of fs.readdirSync(skillsDir).sort()) {
|
||||||
|
const dir = path.join(skillsDir, dirName);
|
||||||
|
if (!fs.statSync(dir).isDirectory()) continue;
|
||||||
|
const file = path.join(dir, "SKILL.md");
|
||||||
|
const rel = path.relative(ROOT, file);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
fail(`skills/${dirName}/`, "missing SKILL.md");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
skillCount++;
|
||||||
|
checkSkill(dirName, rel, fs.readFileSync(file, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
checkVersionSync();
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(`FAIL — ${errors.length} violation(s):\n`);
|
||||||
|
for (const e of errors) console.error(` ✗ ${e}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`OK — ${skillCount} skills validated (${ANATOMY_EXEMPT.size} anatomy-exempt), plugin/marketplace versions in sync.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user