chore: release v4.0.0 - sync 550+ skills and restructure docs
This commit is contained in:
355
scripts/build-catalog.js
Normal file
355
scripts/build-catalog.js
Normal file
@@ -0,0 +1,355 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
listSkillIds,
|
||||
readSkill,
|
||||
tokenize,
|
||||
unique,
|
||||
} = require('../lib/skill-utils');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SKILLS_DIR = path.join(ROOT, 'skills');
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', 'has', 'have', 'in', 'into',
|
||||
'is', 'it', 'its', 'of', 'on', 'or', 'our', 'out', 'over', 'that', 'the', 'their', 'they', 'this',
|
||||
'to', 'use', 'when', 'with', 'you', 'your', 'will', 'can', 'if', 'not', 'only', 'also', 'more',
|
||||
'best', 'practice', 'practices', 'expert', 'specialist', 'focused', 'focus', 'master', 'modern',
|
||||
'advanced', 'comprehensive', 'production', 'production-ready', 'ready', 'build', 'create', 'deliver',
|
||||
'design', 'implement', 'implementation', 'strategy', 'strategies', 'patterns', 'pattern', 'workflow',
|
||||
'workflows', 'guide', 'template', 'templates', 'tool', 'tools', 'project', 'projects', 'support',
|
||||
'manage', 'management', 'system', 'systems', 'services', 'service', 'across', 'end', 'end-to-end',
|
||||
'using', 'based', 'ensure', 'ensure', 'help', 'needs', 'need', 'focuses', 'handles', 'builds', 'make',
|
||||
]);
|
||||
|
||||
const TAG_STOPWORDS = new Set([
|
||||
'pro', 'expert', 'patterns', 'pattern', 'workflow', 'workflows', 'templates', 'template', 'toolkit',
|
||||
'tools', 'tool', 'project', 'projects', 'guide', 'management', 'engineer', 'architect', 'developer',
|
||||
'specialist', 'assistant', 'analysis', 'review', 'reviewer', 'automation', 'orchestration', 'scaffold',
|
||||
'scaffolding', 'implementation', 'strategy', 'context', 'management', 'feature', 'features', 'smart',
|
||||
'system', 'systems', 'design', 'development', 'development', 'test', 'testing', 'workflow',
|
||||
]);
|
||||
|
||||
const CATEGORY_RULES = [
|
||||
{
|
||||
name: 'security',
|
||||
keywords: [
|
||||
'security', 'sast', 'compliance', 'privacy', 'threat', 'vulnerability', 'owasp', 'pci', 'gdpr',
|
||||
'secrets', 'risk', 'malware', 'forensics', 'attack', 'incident', 'auth', 'mtls', 'zero', 'trust',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'infrastructure',
|
||||
keywords: [
|
||||
'kubernetes', 'k8s', 'helm', 'terraform', 'cloud', 'network', 'devops', 'gitops', 'prometheus',
|
||||
'grafana', 'observability', 'monitoring', 'logging', 'tracing', 'deployment', 'istio', 'linkerd',
|
||||
'service', 'mesh', 'slo', 'sre', 'oncall', 'incident', 'pipeline', 'cicd', 'ci', 'cd', 'kafka',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'data-ai',
|
||||
keywords: [
|
||||
'data', 'database', 'db', 'sql', 'postgres', 'mysql', 'analytics', 'etl', 'warehouse', 'dbt',
|
||||
'ml', 'ai', 'llm', 'rag', 'vector', 'embedding', 'spark', 'airflow', 'cdc', 'pipeline',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'development',
|
||||
keywords: [
|
||||
'python', 'javascript', 'typescript', 'java', 'golang', 'go', 'rust', 'csharp', 'dotnet', 'php',
|
||||
'ruby', 'node', 'react', 'frontend', 'backend', 'mobile', 'ios', 'android', 'flutter', 'fastapi',
|
||||
'django', 'nextjs', 'vue', 'api',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'architecture',
|
||||
keywords: [
|
||||
'architecture', 'c4', 'microservices', 'event', 'cqrs', 'saga', 'domain', 'ddd', 'patterns',
|
||||
'decision', 'adr',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'testing',
|
||||
keywords: ['testing', 'tdd', 'unit', 'e2e', 'qa', 'test'],
|
||||
},
|
||||
{
|
||||
name: 'business',
|
||||
keywords: [
|
||||
'business', 'market', 'sales', 'finance', 'startup', 'legal', 'hr', 'product', 'customer', 'seo',
|
||||
'marketing', 'kpi', 'contract', 'employment',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'workflow',
|
||||
keywords: ['workflow', 'orchestration', 'conductor', 'automation', 'process', 'collaboration'],
|
||||
},
|
||||
];
|
||||
|
||||
const BUNDLE_RULES = {
|
||||
'core-dev': {
|
||||
description: 'Core development skills across languages, frameworks, and backend/frontend fundamentals.',
|
||||
keywords: [
|
||||
'python', 'javascript', 'typescript', 'go', 'golang', 'rust', 'java', 'node', 'frontend', 'backend',
|
||||
'react', 'fastapi', 'django', 'nextjs', 'api', 'mobile', 'ios', 'android', 'flutter', 'php', 'ruby',
|
||||
],
|
||||
},
|
||||
'security-core': {
|
||||
description: 'Security, privacy, and compliance essentials.',
|
||||
keywords: [
|
||||
'security', 'sast', 'compliance', 'threat', 'risk', 'privacy', 'secrets', 'owasp', 'gdpr', 'pci',
|
||||
'vulnerability', 'auth',
|
||||
],
|
||||
},
|
||||
'k8s-core': {
|
||||
description: 'Kubernetes and service mesh essentials.',
|
||||
keywords: ['kubernetes', 'k8s', 'helm', 'istio', 'linkerd', 'service', 'mesh'],
|
||||
},
|
||||
'data-core': {
|
||||
description: 'Data engineering and analytics foundations.',
|
||||
keywords: [
|
||||
'data', 'database', 'sql', 'dbt', 'airflow', 'spark', 'analytics', 'etl', 'warehouse', 'postgres',
|
||||
'mysql', 'kafka',
|
||||
],
|
||||
},
|
||||
'ops-core': {
|
||||
description: 'Operations, observability, and delivery pipelines.',
|
||||
keywords: [
|
||||
'observability', 'monitoring', 'logging', 'tracing', 'prometheus', 'grafana', 'devops', 'gitops',
|
||||
'deployment', 'cicd', 'pipeline', 'slo', 'sre', 'incident',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const CURATED_COMMON = [
|
||||
'bash-pro',
|
||||
'python-pro',
|
||||
'javascript-pro',
|
||||
'typescript-pro',
|
||||
'golang-pro',
|
||||
'rust-pro',
|
||||
'java-pro',
|
||||
'frontend-developer',
|
||||
'backend-architect',
|
||||
'nodejs-backend-patterns',
|
||||
'fastapi-pro',
|
||||
'api-design-principles',
|
||||
'sql-pro',
|
||||
'database-architect',
|
||||
'kubernetes-architect',
|
||||
'terraform-specialist',
|
||||
'observability-engineer',
|
||||
'security-auditor',
|
||||
'sast-configuration',
|
||||
'gitops-workflow',
|
||||
];
|
||||
|
||||
function normalizeTokens(tokens) {
|
||||
return unique(tokens.map(token => token.toLowerCase())).filter(Boolean);
|
||||
}
|
||||
|
||||
function deriveTags(skill) {
|
||||
let tags = Array.isArray(skill.tags) ? skill.tags : [];
|
||||
tags = tags.map(tag => tag.toLowerCase()).filter(Boolean);
|
||||
|
||||
if (!tags.length) {
|
||||
tags = skill.id
|
||||
.split('-')
|
||||
.map(tag => tag.toLowerCase())
|
||||
.filter(tag => tag && !TAG_STOPWORDS.has(tag));
|
||||
}
|
||||
|
||||
return normalizeTokens(tags);
|
||||
}
|
||||
|
||||
function detectCategory(skill, tags) {
|
||||
const haystack = normalizeTokens([
|
||||
...tags,
|
||||
...tokenize(skill.name),
|
||||
...tokenize(skill.description),
|
||||
]);
|
||||
const haystackSet = new Set(haystack);
|
||||
|
||||
for (const rule of CATEGORY_RULES) {
|
||||
for (const keyword of rule.keywords) {
|
||||
if (haystackSet.has(keyword)) {
|
||||
return rule.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
function buildTriggers(skill, tags) {
|
||||
const tokens = tokenize(`${skill.name} ${skill.description}`)
|
||||
.filter(token => token.length >= 2 && !STOPWORDS.has(token));
|
||||
return unique([...tags, ...tokens]).slice(0, 12);
|
||||
}
|
||||
|
||||
function buildAliases(skills) {
|
||||
const existingIds = new Set(skills.map(skill => skill.id));
|
||||
const aliases = {};
|
||||
const used = new Set();
|
||||
|
||||
for (const skill of skills) {
|
||||
if (skill.name && skill.name !== skill.id) {
|
||||
const alias = skill.name.toLowerCase();
|
||||
if (!existingIds.has(alias) && !used.has(alias)) {
|
||||
aliases[alias] = skill.id;
|
||||
used.add(alias);
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = skill.id.split('-').filter(Boolean);
|
||||
if (skill.id.length < 28 || tokens.length < 4) continue;
|
||||
|
||||
const deduped = [];
|
||||
const tokenSeen = new Set();
|
||||
for (const token of tokens) {
|
||||
if (tokenSeen.has(token)) continue;
|
||||
tokenSeen.add(token);
|
||||
deduped.push(token);
|
||||
}
|
||||
|
||||
const aliasTokens = deduped.length > 3
|
||||
? [deduped[0], deduped[1], deduped[deduped.length - 1]]
|
||||
: deduped;
|
||||
const alias = unique(aliasTokens).join('-');
|
||||
|
||||
if (!alias || alias === skill.id) continue;
|
||||
if (existingIds.has(alias) || used.has(alias)) continue;
|
||||
|
||||
aliases[alias] = skill.id;
|
||||
used.add(alias);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function buildBundles(skills) {
|
||||
const bundles = {};
|
||||
const skillTokens = new Map();
|
||||
|
||||
for (const skill of skills) {
|
||||
const tokens = normalizeTokens([
|
||||
...skill.tags,
|
||||
...tokenize(skill.name),
|
||||
...tokenize(skill.description),
|
||||
]);
|
||||
skillTokens.set(skill.id, new Set(tokens));
|
||||
}
|
||||
|
||||
for (const [bundleName, rule] of Object.entries(BUNDLE_RULES)) {
|
||||
const bundleSkills = [];
|
||||
const keywords = rule.keywords.map(keyword => keyword.toLowerCase());
|
||||
|
||||
for (const skill of skills) {
|
||||
const tokenSet = skillTokens.get(skill.id) || new Set();
|
||||
if (keywords.some(keyword => tokenSet.has(keyword))) {
|
||||
bundleSkills.push(skill.id);
|
||||
}
|
||||
}
|
||||
|
||||
bundles[bundleName] = {
|
||||
description: rule.description,
|
||||
skills: bundleSkills.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
const common = CURATED_COMMON.filter(skillId => skillTokens.has(skillId));
|
||||
|
||||
return { bundles, common };
|
||||
}
|
||||
|
||||
function truncate(value, limit) {
|
||||
if (!value || value.length <= limit) return value || '';
|
||||
return `${value.slice(0, limit - 3)}...`;
|
||||
}
|
||||
|
||||
function renderCatalogMarkdown(catalog) {
|
||||
const lines = [];
|
||||
lines.push('# Skill Catalog');
|
||||
lines.push('');
|
||||
lines.push(`Generated at: ${catalog.generatedAt}`);
|
||||
lines.push('');
|
||||
lines.push(`Total skills: ${catalog.total}`);
|
||||
lines.push('');
|
||||
|
||||
const categories = Array.from(new Set(catalog.skills.map(skill => skill.category))).sort();
|
||||
for (const category of categories) {
|
||||
const grouped = catalog.skills.filter(skill => skill.category === category);
|
||||
lines.push(`## ${category} (${grouped.length})`);
|
||||
lines.push('');
|
||||
lines.push('| Skill | Description | Tags | Triggers |');
|
||||
lines.push('| --- | --- | --- | --- |');
|
||||
|
||||
for (const skill of grouped) {
|
||||
const description = truncate(skill.description, 160).replace(/\|/g, '\\|');
|
||||
const tags = skill.tags.join(', ');
|
||||
const triggers = skill.triggers.join(', ');
|
||||
lines.push(`| \`${skill.id}\` | ${description} | ${tags} | ${triggers} |`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildCatalog() {
|
||||
const skillIds = listSkillIds(SKILLS_DIR);
|
||||
const skills = skillIds.map(skillId => readSkill(SKILLS_DIR, skillId));
|
||||
const catalogSkills = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const tags = deriveTags(skill);
|
||||
const category = detectCategory(skill, tags);
|
||||
const triggers = buildTriggers(skill, tags);
|
||||
|
||||
catalogSkills.push({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
category,
|
||||
tags,
|
||||
triggers,
|
||||
path: path.relative(ROOT, skill.path),
|
||||
});
|
||||
}
|
||||
|
||||
const catalog = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
total: catalogSkills.length,
|
||||
skills: catalogSkills.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
};
|
||||
|
||||
const aliases = buildAliases(catalog.skills);
|
||||
const bundleData = buildBundles(catalog.skills);
|
||||
|
||||
const catalogPath = path.join(ROOT, 'catalog.json');
|
||||
const catalogMarkdownPath = path.join(ROOT, 'CATALOG.md');
|
||||
const bundlesPath = path.join(ROOT, 'bundles.json');
|
||||
const aliasesPath = path.join(ROOT, 'aliases.json');
|
||||
|
||||
fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2));
|
||||
fs.writeFileSync(catalogMarkdownPath, renderCatalogMarkdown(catalog));
|
||||
fs.writeFileSync(
|
||||
bundlesPath,
|
||||
JSON.stringify({ generatedAt: catalog.generatedAt, ...bundleData }, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
aliasesPath,
|
||||
JSON.stringify({ generatedAt: catalog.generatedAt, aliases }, null, 2),
|
||||
);
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const catalog = buildCatalog();
|
||||
console.log(`Generated catalog for ${catalog.total} skills.`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCatalog,
|
||||
};
|
||||
149
scripts/normalize-frontmatter.js
Normal file
149
scripts/normalize-frontmatter.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('yaml');
|
||||
const { listSkillIds, parseFrontmatter } = require('../lib/skill-utils');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SKILLS_DIR = path.join(ROOT, 'skills');
|
||||
const ALLOWED_FIELDS = new Set([
|
||||
'name',
|
||||
'description',
|
||||
'license',
|
||||
'compatibility',
|
||||
'metadata',
|
||||
'allowed-tools',
|
||||
]);
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function coerceToString(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) {
|
||||
const simple = value.every(item => ['string', 'number', 'boolean'].includes(typeof item));
|
||||
return simple ? value.map(item => String(item).trim()).filter(Boolean).join(', ') : JSON.stringify(value);
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function appendMetadata(metadata, key, value) {
|
||||
const nextValue = coerceToString(value);
|
||||
if (!nextValue) return;
|
||||
if (!metadata[key]) {
|
||||
metadata[key] = nextValue;
|
||||
return;
|
||||
}
|
||||
if (metadata[key].includes(nextValue)) return;
|
||||
metadata[key] = `${metadata[key]}, ${nextValue}`;
|
||||
}
|
||||
|
||||
function collectAllowedTools(value, toolSet) {
|
||||
if (!value) return;
|
||||
if (typeof value === 'string') {
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.map(token => token.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(token => toolSet.add(token));
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value
|
||||
.map(token => String(token).trim())
|
||||
.filter(Boolean)
|
||||
.forEach(token => toolSet.add(token));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSkill(skillId) {
|
||||
const skillPath = path.join(SKILLS_DIR, skillId, 'SKILL.md');
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
const { data, body, hasFrontmatter } = parseFrontmatter(content);
|
||||
|
||||
if (!hasFrontmatter) return false;
|
||||
|
||||
let modified = false;
|
||||
const updated = { ...data };
|
||||
const metadata = isPlainObject(updated.metadata) ? { ...updated.metadata } : {};
|
||||
if (updated.metadata !== undefined && !isPlainObject(updated.metadata)) {
|
||||
appendMetadata(metadata, 'legacy_metadata', updated.metadata);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
const allowedTools = new Set();
|
||||
collectAllowedTools(updated['allowed-tools'], allowedTools);
|
||||
collectAllowedTools(updated.tools, allowedTools);
|
||||
collectAllowedTools(updated.tool_access, allowedTools);
|
||||
|
||||
if (updated.tools !== undefined) {
|
||||
delete updated.tools;
|
||||
modified = true;
|
||||
}
|
||||
if (updated.tool_access !== undefined) {
|
||||
delete updated.tool_access;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(updated)) {
|
||||
if (ALLOWED_FIELDS.has(key)) continue;
|
||||
if (key === 'tags') {
|
||||
appendMetadata(metadata, 'tags', updated[key]);
|
||||
} else {
|
||||
appendMetadata(metadata, key, updated[key]);
|
||||
}
|
||||
delete updated[key];
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (allowedTools.size) {
|
||||
updated['allowed-tools'] = Array.from(allowedTools).join(' ');
|
||||
modified = true;
|
||||
} else if (updated['allowed-tools'] !== undefined) {
|
||||
delete updated['allowed-tools'];
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (Object.keys(metadata).length) {
|
||||
updated.metadata = metadata;
|
||||
modified = true;
|
||||
} else if (updated.metadata !== undefined) {
|
||||
delete updated.metadata;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (!modified) return false;
|
||||
|
||||
const ordered = {};
|
||||
for (const key of ['name', 'description', 'license', 'compatibility', 'allowed-tools', 'metadata']) {
|
||||
if (updated[key] !== undefined) {
|
||||
ordered[key] = updated[key];
|
||||
}
|
||||
}
|
||||
|
||||
const fm = yaml.stringify(ordered).trimEnd();
|
||||
const bodyPrefix = body.length && (body.startsWith('\n') || body.startsWith('\r\n')) ? '' : '\n';
|
||||
const next = `---\n${fm}\n---${bodyPrefix}${body}`;
|
||||
fs.writeFileSync(skillPath, next);
|
||||
return true;
|
||||
}
|
||||
|
||||
function run() {
|
||||
const skillIds = listSkillIds(SKILLS_DIR);
|
||||
let updatedCount = 0;
|
||||
for (const skillId of skillIds) {
|
||||
if (normalizeSkill(skillId)) updatedCount += 1;
|
||||
}
|
||||
console.log(`Normalized frontmatter for ${updatedCount} skills.`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
run();
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
55
scripts/release_cycle.sh
Executable file
55
scripts/release_cycle.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Release Cycle Automation Script
|
||||
# Enforces protocols from .github/MAINTENANCE.md
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}🤖 Initiating Antigravity Release Protocol...${NC}"
|
||||
|
||||
# 1. Validation Chain
|
||||
echo -e "\n${YELLOW}Step 1: Running Validation Chain...${NC}"
|
||||
echo "Running validate_skills.py..."
|
||||
python3 scripts/validate_skills.py
|
||||
echo "Running generate_index.py..."
|
||||
python3 scripts/generate_index.py
|
||||
echo "Running update_readme.py..."
|
||||
python3 scripts/update_readme.py
|
||||
|
||||
# 2. Stats Consistency Check
|
||||
echo -e "\n${YELLOW}Step 2: verifying Stats Consistency...${NC}"
|
||||
JSON_COUNT=$(python3 -c "import json; print(len(json.load(open('skills_index.json'))))")
|
||||
echo "Skills in Registry (JSON): $JSON_COUNT"
|
||||
|
||||
# Check README Intro
|
||||
README_CONTENT=$(cat README.md)
|
||||
if [[ "$README_CONTENT" != *"$JSON_COUNT high-performance"* ]]; then
|
||||
echo -e "${RED}❌ ERROR: README.md intro consistency failure!${NC}"
|
||||
echo "Expected: '$JSON_COUNT high-performance'"
|
||||
echo "Found mismatch. Please grep for 'high-performance' in README.md and fix it."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ Stats Consistent.${NC}"
|
||||
|
||||
# 3. Contributor Check
|
||||
echo -e "\n${YELLOW}Step 3: Contributor Check${NC}"
|
||||
echo "Recent commits by author (check against README 'Repo Contributors'):"
|
||||
git shortlog -sn --since="1 month ago" --all --no-merges | head -n 10
|
||||
|
||||
echo -e "${YELLOW}⚠️ MANUAL VERIFICATION REQUIRED:${NC}"
|
||||
echo "1. Are all PR authors above listed in 'Repo Contributors'?"
|
||||
echo "2. Are all External Sources listed in 'Credits & Sources'?"
|
||||
read -p "Type 'yes' to confirm you have verified contributors: " CONFIRM_CONTRIB
|
||||
|
||||
if [ "$CONFIRM_CONTRIB" != "yes" ]; then
|
||||
echo -e "${RED}❌ Verification failed. Aborting.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}✅ Release Cycle Checks Passed. You may now commit and push.${NC}"
|
||||
exit 0
|
||||
266
scripts/validate-skills.js
Normal file
266
scripts/validate-skills.js
Normal file
@@ -0,0 +1,266 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { listSkillIds, parseFrontmatter } = require('../lib/skill-utils');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SKILLS_DIR = path.join(ROOT, 'skills');
|
||||
const BASELINE_PATH = path.join(ROOT, 'validation-baseline.json');
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const missingUseSection = [];
|
||||
const missingDoNotUseSection = [];
|
||||
const missingInstructionsSection = [];
|
||||
const longFiles = [];
|
||||
const unknownFieldSkills = [];
|
||||
const isStrict = process.argv.includes('--strict')
|
||||
|| process.env.STRICT === '1'
|
||||
|| process.env.STRICT === 'true';
|
||||
const writeBaseline = process.argv.includes('--write-baseline')
|
||||
|| process.env.WRITE_BASELINE === '1'
|
||||
|| process.env.WRITE_BASELINE === 'true';
|
||||
|
||||
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
const MAX_COMPATIBILITY_LENGTH = 500;
|
||||
const MAX_SKILL_LINES = 500;
|
||||
const ALLOWED_FIELDS = new Set([
|
||||
'name',
|
||||
'description',
|
||||
'license',
|
||||
'compatibility',
|
||||
'metadata',
|
||||
'allowed-tools',
|
||||
]);
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateStringField(fieldName, value, { min = 1, max = Infinity } = {}) {
|
||||
if (typeof value !== 'string') {
|
||||
return `${fieldName} must be a string.`;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return `${fieldName} cannot be empty.`;
|
||||
}
|
||||
if (trimmed.length < min) {
|
||||
return `${fieldName} must be at least ${min} characters.`;
|
||||
}
|
||||
if (trimmed.length > max) {
|
||||
return `${fieldName} must be <= ${max} characters.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addError(message) {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
function addWarning(message) {
|
||||
warnings.push(message);
|
||||
}
|
||||
|
||||
function loadBaseline() {
|
||||
if (!fs.existsSync(BASELINE_PATH)) {
|
||||
return {
|
||||
useSection: [],
|
||||
doNotUseSection: [],
|
||||
instructionsSection: [],
|
||||
longFile: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
||||
return {
|
||||
useSection: Array.isArray(parsed.useSection) ? parsed.useSection : [],
|
||||
doNotUseSection: Array.isArray(parsed.doNotUseSection) ? parsed.doNotUseSection : [],
|
||||
instructionsSection: Array.isArray(parsed.instructionsSection) ? parsed.instructionsSection : [],
|
||||
longFile: Array.isArray(parsed.longFile) ? parsed.longFile : [],
|
||||
};
|
||||
} catch (err) {
|
||||
addWarning('Failed to parse validation-baseline.json; strict mode may fail.');
|
||||
return { useSection: [], doNotUseSection: [], instructionsSection: [], longFile: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function addStrictSectionErrors(label, missing, baselineSet) {
|
||||
if (!isStrict) return;
|
||||
const strictMissing = missing.filter(skillId => !baselineSet.has(skillId));
|
||||
if (strictMissing.length) {
|
||||
addError(`Missing "${label}" section (strict): ${strictMissing.length} skills (examples: ${strictMissing.slice(0, 5).join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (!content.includes('## Use this skill when')) {
|
||||
missingUseSection.push(skillId);
|
||||
}
|
||||
|
||||
if (!content.includes('## Do not use')) {
|
||||
missingDoNotUseSection.push(skillId);
|
||||
}
|
||||
|
||||
if (!content.includes('## Instructions')) {
|
||||
missingInstructionsSection.push(skillId);
|
||||
}
|
||||
}
|
||||
|
||||
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 (errors.length) {
|
||||
console.error('\nErrors:');
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Validation passed for ${skillIds.length} skills.`);
|
||||
Reference in New Issue
Block a user