mirror of
https://github.com/supabase/agent-skills.git
synced 2026-03-27 10:09:26 +08:00
refactor: generic skills build system with auto-discovery (#8)
* refactor: generic skills build system with auto-discovery - Rename postgres-best-practices-build → skills-build - Add auto-discovery: scans skills/ for subdirectories with metadata.json - Build/validate all skills or specific skill with -- argument - Update root AGENTS.md and CONTRIBUTING.md with new structure - No configuration needed to add new skills Usage: npm run build # Build all skills npm run build -- skill-name # Build specific skill npm run validate # Validate all skills Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix ci * more generic impact levels --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
314
packages/skills-build/src/build.ts
Normal file
314
packages/skills-build/src/build.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import {
|
||||
discoverSkills,
|
||||
getSkillPaths,
|
||||
type SkillPaths,
|
||||
validateSkillExists,
|
||||
} from "./config.js";
|
||||
import { parseRuleFile } from "./parser.js";
|
||||
import type { Metadata, Rule, Section } from "./types.js";
|
||||
import { validateRuleFile } from "./validate.js";
|
||||
|
||||
/**
|
||||
* Parse section definitions from _sections.md
|
||||
*/
|
||||
function parseSections(rulesDir: string): Section[] {
|
||||
const sectionsFile = join(rulesDir, "_sections.md");
|
||||
if (!existsSync(sectionsFile)) {
|
||||
console.warn("Warning: _sections.md not found, using empty sections");
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = readFileSync(sectionsFile, "utf-8");
|
||||
const sections: Section[] = [];
|
||||
|
||||
// Match format: Impact and Description on separate lines
|
||||
// ## 1. Query Performance (query)
|
||||
// **Impact:** CRITICAL
|
||||
// **Description:** Description text
|
||||
const sectionMatches = content.matchAll(
|
||||
/##\s+(\d+)\.\s+([^\n(]+)\s*\((\w+)\)\s*\n\*\*Impact:\*\*\s*(\w+(?:-\w+)?)\s*\n\*\*Description:\*\*\s*([^\n]+)/g,
|
||||
);
|
||||
|
||||
for (const match of sectionMatches) {
|
||||
sections.push({
|
||||
number: parseInt(match[1], 10),
|
||||
title: match[2].trim(),
|
||||
prefix: match[3].trim(),
|
||||
impact: match[4].trim() as Section["impact"],
|
||||
description: match[5].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load metadata from metadata.json
|
||||
*/
|
||||
function loadMetadata(metadataFile: string, skillName: string): Metadata {
|
||||
if (!existsSync(metadataFile)) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
organization: "Supabase",
|
||||
date: new Date().toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
abstract: `${skillName} guide for developers.`,
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.parse(readFileSync(metadataFile, "utf-8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate anchor from title
|
||||
*/
|
||||
function toAnchor(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert skill name to title (e.g., "postgres-best-practices" -> "Postgres Best Practices")
|
||||
*/
|
||||
function skillNameToTitle(skillName: string): string {
|
||||
return skillName
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SECTION_MAP from parsed sections
|
||||
*/
|
||||
export function generateSectionMap(
|
||||
sections: Section[],
|
||||
): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
for (const section of sections) {
|
||||
map[section.prefix] = section.number;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AGENTS.md for a specific skill
|
||||
*/
|
||||
function buildSkill(paths: SkillPaths): void {
|
||||
console.log(`[${paths.name}] Building AGENTS.md...`);
|
||||
|
||||
// Load metadata and sections
|
||||
const metadata = loadMetadata(paths.metadataFile, paths.name);
|
||||
const sections = parseSections(paths.rulesDir);
|
||||
const sectionMap = generateSectionMap(sections);
|
||||
const skillTitle = skillNameToTitle(paths.name);
|
||||
|
||||
// Check if rules directory exists
|
||||
if (!existsSync(paths.rulesDir)) {
|
||||
console.log(` No rules directory found. Generating empty AGENTS.md.`);
|
||||
writeFileSync(
|
||||
paths.agentsOutput,
|
||||
`# ${skillTitle}\n\nNo rules defined yet.\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all rule files
|
||||
const ruleFiles = readdirSync(paths.rulesDir)
|
||||
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
||||
.map((f) => join(paths.rulesDir, f));
|
||||
|
||||
if (ruleFiles.length === 0) {
|
||||
console.log(` No rule files found. Generating empty AGENTS.md.`);
|
||||
}
|
||||
|
||||
// Parse and validate all rules
|
||||
const rules: Rule[] = [];
|
||||
|
||||
for (const file of ruleFiles) {
|
||||
const validation = validateRuleFile(file, sectionMap);
|
||||
if (!validation.valid) {
|
||||
console.error(` Skipping invalid file ${basename(file)}:`);
|
||||
for (const e of validation.errors) {
|
||||
console.error(` - ${e}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = parseRuleFile(file, sectionMap);
|
||||
if (result.success && result.rule) {
|
||||
rules.push(result.rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Group rules by section and assign IDs
|
||||
const rulesBySection = new Map<number, Rule[]>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const sectionRules = rulesBySection.get(rule.section) || [];
|
||||
sectionRules.push(rule);
|
||||
rulesBySection.set(rule.section, sectionRules);
|
||||
}
|
||||
|
||||
// Sort rules within each section and assign IDs
|
||||
for (const [sectionNum, sectionRules] of rulesBySection) {
|
||||
sectionRules.sort((a, b) => a.title.localeCompare(b.title));
|
||||
sectionRules.forEach((rule, index) => {
|
||||
rule.id = `${sectionNum}.${index + 1}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Generate markdown output
|
||||
const output: string[] = [];
|
||||
|
||||
// Header
|
||||
output.push(`# ${skillTitle}\n`);
|
||||
output.push(`**Version ${metadata.version}**`);
|
||||
output.push(`${metadata.organization}`);
|
||||
output.push(`${metadata.date}\n`);
|
||||
output.push(
|
||||
"> This document is optimized for AI agents and LLMs. Rules are prioritized by performance impact.\n",
|
||||
);
|
||||
output.push("---\n");
|
||||
|
||||
// Abstract
|
||||
output.push("## Abstract\n");
|
||||
output.push(`${metadata.abstract}\n`);
|
||||
output.push("---\n");
|
||||
|
||||
// Table of Contents
|
||||
output.push("## Table of Contents\n");
|
||||
|
||||
for (const section of sections) {
|
||||
const sectionRules = rulesBySection.get(section.number) || [];
|
||||
output.push(
|
||||
`${section.number}. [${section.title}](#${toAnchor(section.title)}) - **${section.impact}**`,
|
||||
);
|
||||
|
||||
for (const rule of sectionRules) {
|
||||
output.push(
|
||||
` - ${rule.id} [${rule.title}](#${toAnchor(`${rule.id}-${rule.title}`)})`,
|
||||
);
|
||||
}
|
||||
|
||||
output.push("");
|
||||
}
|
||||
|
||||
output.push("---\n");
|
||||
|
||||
// Sections and Rules
|
||||
for (const section of sections) {
|
||||
const sectionRules = rulesBySection.get(section.number) || [];
|
||||
|
||||
output.push(`## ${section.number}. ${section.title}\n`);
|
||||
output.push(`**Impact: ${section.impact}**\n`);
|
||||
output.push(`${section.description}\n`);
|
||||
|
||||
if (sectionRules.length === 0) {
|
||||
output.push(
|
||||
"*No rules defined yet. See rules/_template.md for creating new rules.*\n",
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of sectionRules) {
|
||||
output.push(`### ${rule.id} ${rule.title}\n`);
|
||||
|
||||
if (rule.impactDescription) {
|
||||
output.push(`**Impact: ${rule.impact} (${rule.impactDescription})**\n`);
|
||||
} else {
|
||||
output.push(`**Impact: ${rule.impact}**\n`);
|
||||
}
|
||||
|
||||
output.push(`${rule.explanation}\n`);
|
||||
|
||||
for (const example of rule.examples) {
|
||||
if (example.description) {
|
||||
output.push(`**${example.label} (${example.description}):**\n`);
|
||||
} else {
|
||||
output.push(`**${example.label}:**\n`);
|
||||
}
|
||||
|
||||
output.push(`\`\`\`${example.language || "sql"}`);
|
||||
output.push(example.code);
|
||||
output.push("```\n");
|
||||
|
||||
if (example.additionalText) {
|
||||
output.push(`${example.additionalText}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.references && rule.references.length > 0) {
|
||||
if (rule.references.length === 1) {
|
||||
output.push(`Reference: ${rule.references[0]}\n`);
|
||||
} else {
|
||||
output.push("References:");
|
||||
for (const ref of rule.references) {
|
||||
output.push(`- ${ref}`);
|
||||
}
|
||||
output.push("");
|
||||
}
|
||||
}
|
||||
|
||||
output.push("---\n");
|
||||
}
|
||||
}
|
||||
|
||||
// References section
|
||||
if (metadata.references && metadata.references.length > 0) {
|
||||
output.push("## References\n");
|
||||
for (const ref of metadata.references) {
|
||||
output.push(`- ${ref}`);
|
||||
}
|
||||
output.push("");
|
||||
}
|
||||
|
||||
// Write output
|
||||
writeFileSync(paths.agentsOutput, output.join("\n"));
|
||||
console.log(` Generated: ${paths.agentsOutput}`);
|
||||
console.log(` Total rules: ${rules.length}`);
|
||||
}
|
||||
|
||||
// Run build when executed directly
|
||||
const isMainModule =
|
||||
process.argv[1]?.endsWith("build.ts") ||
|
||||
process.argv[1]?.endsWith("build.js");
|
||||
|
||||
if (isMainModule) {
|
||||
const targetSkill = process.argv[2];
|
||||
|
||||
if (targetSkill) {
|
||||
// Build specific skill
|
||||
if (!validateSkillExists(targetSkill)) {
|
||||
console.error(`Error: Skill "${targetSkill}" not found in skills/`);
|
||||
const available = discoverSkills();
|
||||
if (available.length > 0) {
|
||||
console.error(`Available skills: ${available.join(", ")}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
buildSkill(getSkillPaths(targetSkill));
|
||||
} else {
|
||||
// Build all skills
|
||||
const skills = discoverSkills();
|
||||
if (skills.length === 0) {
|
||||
console.log("No skills found in skills/ directory.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${skills.length} skill(s): ${skills.join(", ")}\n`);
|
||||
for (const skill of skills) {
|
||||
buildSkill(getSkillPaths(skill));
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ Done!");
|
||||
}
|
||||
|
||||
export { buildSkill, parseSections };
|
||||
59
packages/skills-build/src/config.ts
Normal file
59
packages/skills-build/src/config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Build package directory
|
||||
export const BUILD_DIR = join(__dirname, "..");
|
||||
|
||||
// Skills root directory
|
||||
export const SKILLS_ROOT = join(BUILD_DIR, "../../skills");
|
||||
|
||||
// Skill paths interface
|
||||
export interface SkillPaths {
|
||||
name: string;
|
||||
skillDir: string;
|
||||
rulesDir: string;
|
||||
agentsOutput: string;
|
||||
metadataFile: string;
|
||||
}
|
||||
|
||||
// Discover all valid skills (directories with metadata.json)
|
||||
export function discoverSkills(): string[] {
|
||||
if (!existsSync(SKILLS_ROOT)) return [];
|
||||
|
||||
return readdirSync(SKILLS_ROOT, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.filter((d) => existsSync(join(SKILLS_ROOT, d.name, "metadata.json")))
|
||||
.map((d) => d.name);
|
||||
}
|
||||
|
||||
// Get paths for a specific skill
|
||||
export function getSkillPaths(skillName: string): SkillPaths {
|
||||
const skillDir = join(SKILLS_ROOT, skillName);
|
||||
return {
|
||||
name: skillName,
|
||||
skillDir,
|
||||
rulesDir: join(skillDir, "rules"),
|
||||
agentsOutput: join(skillDir, "AGENTS.md"),
|
||||
metadataFile: join(skillDir, "metadata.json"),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate skill exists
|
||||
export function validateSkillExists(skillName: string): boolean {
|
||||
const paths = getSkillPaths(skillName);
|
||||
return existsSync(paths.metadataFile);
|
||||
}
|
||||
|
||||
// Valid impact levels in priority order
|
||||
export const IMPACT_LEVELS = [
|
||||
"CRITICAL",
|
||||
"HIGH",
|
||||
"MEDIUM-HIGH",
|
||||
"MEDIUM",
|
||||
"LOW-MEDIUM",
|
||||
"LOW",
|
||||
] as const;
|
||||
281
packages/skills-build/src/parser.ts
Normal file
281
packages/skills-build/src/parser.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import { IMPACT_LEVELS } from "./config.js";
|
||||
import type { CodeExample, ImpactLevel, ParseResult, Rule } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse YAML-style frontmatter from markdown content
|
||||
*/
|
||||
function parseFrontmatter(content: string): {
|
||||
frontmatter: Record<string, string>;
|
||||
body: string;
|
||||
} {
|
||||
const frontmatter: Record<string, string> = {};
|
||||
|
||||
if (!content.startsWith("---")) {
|
||||
return { frontmatter, body: content };
|
||||
}
|
||||
|
||||
const endIndex = content.indexOf("---", 3);
|
||||
if (endIndex === -1) {
|
||||
return { frontmatter, body: content };
|
||||
}
|
||||
|
||||
const frontmatterContent = content.slice(3, endIndex).trim();
|
||||
const body = content.slice(endIndex + 3).trim();
|
||||
|
||||
for (const line of frontmatterContent.split("\n")) {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
let value = line.slice(colonIndex + 1).trim();
|
||||
|
||||
// Strip quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract section number from filename prefix
|
||||
*/
|
||||
function getSectionFromFilename(
|
||||
filename: string,
|
||||
sectionMap: Record<string, number>,
|
||||
): number | null {
|
||||
const base = basename(filename, ".md");
|
||||
const prefix = base.split("-")[0];
|
||||
return sectionMap[prefix] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract code examples from markdown body
|
||||
*/
|
||||
function extractExamples(body: string): CodeExample[] {
|
||||
const examples: CodeExample[] = [];
|
||||
const lines = body.split("\n");
|
||||
|
||||
let currentLabel = "";
|
||||
let currentDescription = "";
|
||||
let inCodeBlock = false;
|
||||
let codeBlockLang = "";
|
||||
let codeBlockContent: string[] = [];
|
||||
let additionalText: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Check for example label: **Label:** or **Label (description):**
|
||||
const labelMatch = line.match(
|
||||
/^\*\*([^*]+?)(?:\s*\(([^)]+)\))?\s*:\*\*\s*$/,
|
||||
);
|
||||
if (labelMatch && !inCodeBlock) {
|
||||
// Save previous example if exists
|
||||
if (currentLabel && codeBlockContent.length > 0) {
|
||||
examples.push({
|
||||
label: currentLabel,
|
||||
description: currentDescription || undefined,
|
||||
code: codeBlockContent.join("\n"),
|
||||
language: codeBlockLang || undefined,
|
||||
additionalText:
|
||||
additionalText.length > 0
|
||||
? additionalText.join("\n").trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
currentLabel = labelMatch[1].trim();
|
||||
currentDescription = labelMatch[2]?.trim() || "";
|
||||
codeBlockContent = [];
|
||||
codeBlockLang = "";
|
||||
additionalText = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for code block start
|
||||
if (line.startsWith("```") && !inCodeBlock) {
|
||||
inCodeBlock = true;
|
||||
codeBlockLang = line.slice(3).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for code block end
|
||||
if (line.startsWith("```") && inCodeBlock) {
|
||||
inCodeBlock = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect code block content
|
||||
if (inCodeBlock) {
|
||||
codeBlockContent.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect additional text after code block (before next label)
|
||||
if (currentLabel && codeBlockContent.length > 0 && line.trim()) {
|
||||
// Stop collecting if we hit a heading or reference
|
||||
if (line.startsWith("#") || line.startsWith("Reference")) {
|
||||
continue;
|
||||
}
|
||||
additionalText.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last example
|
||||
if (currentLabel && codeBlockContent.length > 0) {
|
||||
examples.push({
|
||||
label: currentLabel,
|
||||
description: currentDescription || undefined,
|
||||
code: codeBlockContent.join("\n"),
|
||||
language: codeBlockLang || undefined,
|
||||
additionalText:
|
||||
additionalText.length > 0
|
||||
? additionalText.join("\n").trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return examples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract title from first ## heading
|
||||
*/
|
||||
function extractTitle(body: string): string | null {
|
||||
const match = body.match(/^##\s+(.+)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract explanation (content between title and first example)
|
||||
*/
|
||||
function extractExplanation(body: string): string {
|
||||
const lines = body.split("\n");
|
||||
const explanationLines: string[] = [];
|
||||
let foundTitle = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("## ")) {
|
||||
foundTitle = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!foundTitle) continue;
|
||||
|
||||
// Stop at first example label or code block
|
||||
if (line.match(/^\*\*[^*]+:\*\*/) || line.startsWith("```")) {
|
||||
break;
|
||||
}
|
||||
|
||||
explanationLines.push(line);
|
||||
}
|
||||
|
||||
return explanationLines.join("\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract references from body
|
||||
*/
|
||||
function extractReferences(body: string): string[] {
|
||||
const references: string[] = [];
|
||||
const lines = body.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Match "Reference: [text](url)" or "- [text](url)" after "References:"
|
||||
const refMatch = line.match(/Reference:\s*\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (refMatch) {
|
||||
references.push(refMatch[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match list items under References section
|
||||
const listMatch = line.match(/^-\s*\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (listMatch) {
|
||||
references.push(listMatch[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a rule file and return structured data
|
||||
*/
|
||||
export function parseRuleFile(
|
||||
filePath: string,
|
||||
sectionMap: Record<string, number>,
|
||||
): ParseResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
// Extract section from filename
|
||||
const section = getSectionFromFilename(filePath, sectionMap);
|
||||
if (section === null) {
|
||||
errors.push(
|
||||
`Could not determine section from filename: ${basename(filePath)}`,
|
||||
);
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Get title from frontmatter or body
|
||||
const title = frontmatter.title || extractTitle(body);
|
||||
if (!title) {
|
||||
errors.push("Missing title in frontmatter or body");
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Get impact level
|
||||
const impact = frontmatter.impact as ImpactLevel;
|
||||
if (!impact || !IMPACT_LEVELS.includes(impact)) {
|
||||
errors.push(
|
||||
`Invalid or missing impact level: ${impact}. Must be one of: ${IMPACT_LEVELS.join(", ")}`,
|
||||
);
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Extract other fields
|
||||
const explanation = extractExplanation(body);
|
||||
const examples = extractExamples(body);
|
||||
|
||||
const tags = frontmatter.tags?.split(",").map((t) => t.trim()) || [];
|
||||
|
||||
// Validation warnings
|
||||
if (!explanation || explanation.length < 20) {
|
||||
warnings.push("Explanation is very short or missing");
|
||||
}
|
||||
|
||||
if (examples.length === 0) {
|
||||
warnings.push("No code examples found");
|
||||
}
|
||||
|
||||
const rule: Rule = {
|
||||
id: "", // Will be assigned during build
|
||||
title,
|
||||
section,
|
||||
impact,
|
||||
impactDescription: frontmatter.impactDescription,
|
||||
explanation,
|
||||
examples,
|
||||
references: extractReferences(body),
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
};
|
||||
|
||||
return { success: true, rule, errors, warnings };
|
||||
} catch (error) {
|
||||
errors.push(`Failed to parse file: ${error}`);
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
}
|
||||
59
packages/skills-build/src/types.ts
Normal file
59
packages/skills-build/src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type ImpactLevel =
|
||||
| "CRITICAL"
|
||||
| "HIGH"
|
||||
| "MEDIUM-HIGH"
|
||||
| "MEDIUM"
|
||||
| "LOW-MEDIUM"
|
||||
| "LOW";
|
||||
|
||||
export interface CodeExample {
|
||||
label: string;
|
||||
description?: string;
|
||||
code: string;
|
||||
language?: string;
|
||||
additionalText?: string;
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: string;
|
||||
title: string;
|
||||
section: number;
|
||||
subsection?: number;
|
||||
impact: ImpactLevel;
|
||||
impactDescription?: string;
|
||||
explanation: string;
|
||||
examples: CodeExample[];
|
||||
references?: string[];
|
||||
tags?: string[];
|
||||
supabaseNotes?: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
number: number;
|
||||
title: string;
|
||||
prefix: string;
|
||||
impact: ImpactLevel;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
version: string;
|
||||
organization: string;
|
||||
date: string;
|
||||
abstract: string;
|
||||
references: string[];
|
||||
maintainers?: string[];
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
rule?: Rule;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
248
packages/skills-build/src/validate.ts
Normal file
248
packages/skills-build/src/validate.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { generateSectionMap, parseSections } from "./build.js";
|
||||
import {
|
||||
discoverSkills,
|
||||
getSkillPaths,
|
||||
IMPACT_LEVELS,
|
||||
type SkillPaths,
|
||||
validateSkillExists,
|
||||
} from "./config.js";
|
||||
import { parseRuleFile } from "./parser.js";
|
||||
import type { ValidationResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Check if an example label indicates a "bad" pattern
|
||||
*/
|
||||
function isBadExample(label: string): boolean {
|
||||
const lower = label.toLowerCase();
|
||||
return (
|
||||
lower.includes("incorrect") ||
|
||||
lower.includes("wrong") ||
|
||||
lower.includes("bad")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an example label indicates a "good" pattern
|
||||
*/
|
||||
function isGoodExample(label: string): boolean {
|
||||
const lower = label.toLowerCase();
|
||||
return (
|
||||
lower.includes("correct") ||
|
||||
lower.includes("good") ||
|
||||
lower.includes("usage") ||
|
||||
lower.includes("implementation") ||
|
||||
lower.includes("example") ||
|
||||
lower.includes("recommended")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single rule file
|
||||
*/
|
||||
export function validateRuleFile(
|
||||
filePath: string,
|
||||
sectionMap?: Record<string, number>,
|
||||
rulesDir?: string,
|
||||
): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Generate section map if not provided
|
||||
if (!sectionMap && rulesDir) {
|
||||
const sections = parseSections(rulesDir);
|
||||
sectionMap = generateSectionMap(sections);
|
||||
} else if (!sectionMap) {
|
||||
sectionMap = {};
|
||||
}
|
||||
|
||||
const result = parseRuleFile(filePath, sectionMap);
|
||||
|
||||
// Add parser errors and warnings
|
||||
errors.push(...result.errors);
|
||||
warnings.push(...result.warnings);
|
||||
|
||||
if (!result.success || !result.rule) {
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const rule = result.rule;
|
||||
|
||||
// Validate title
|
||||
if (!rule.title || rule.title.trim().length === 0) {
|
||||
errors.push("Missing or empty title");
|
||||
}
|
||||
|
||||
// Validate explanation
|
||||
if (!rule.explanation || rule.explanation.trim().length === 0) {
|
||||
errors.push("Missing or empty explanation");
|
||||
} else if (rule.explanation.length < 50) {
|
||||
warnings.push("Explanation is shorter than 50 characters");
|
||||
}
|
||||
|
||||
// Validate examples
|
||||
if (rule.examples.length === 0) {
|
||||
errors.push(
|
||||
"Missing examples (need at least one bad and one good example)",
|
||||
);
|
||||
} else {
|
||||
const hasBad = rule.examples.some((e) => isBadExample(e.label));
|
||||
const hasGood = rule.examples.some((e) => isGoodExample(e.label));
|
||||
|
||||
if (!hasBad && !hasGood) {
|
||||
errors.push("Missing bad/incorrect and good/correct examples");
|
||||
} else if (!hasBad) {
|
||||
warnings.push("Missing bad/incorrect example (recommended for clarity)");
|
||||
} else if (!hasGood) {
|
||||
errors.push("Missing good/correct example");
|
||||
}
|
||||
|
||||
// Check for code in examples
|
||||
const hasCode = rule.examples.some(
|
||||
(e) => e.code && e.code.trim().length > 0,
|
||||
);
|
||||
if (!hasCode) {
|
||||
errors.push("Examples have no code");
|
||||
}
|
||||
|
||||
// Check for language specification
|
||||
for (const example of rule.examples) {
|
||||
if (example.code && !example.language) {
|
||||
warnings.push(
|
||||
`Example "${example.label}" missing language specification`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate impact level
|
||||
if (!IMPACT_LEVELS.includes(rule.impact)) {
|
||||
errors.push(
|
||||
`Invalid impact level: ${rule.impact}. Must be one of: ${IMPACT_LEVELS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Warning for missing impact description
|
||||
if (!rule.impactDescription) {
|
||||
warnings.push(
|
||||
"Missing impactDescription (recommended for quantifying benefit)",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all rule files for a skill
|
||||
*/
|
||||
function validateSkill(paths: SkillPaths): boolean {
|
||||
console.log(`[${paths.name}] Validating...`);
|
||||
|
||||
// Check if rules directory exists
|
||||
if (!existsSync(paths.rulesDir)) {
|
||||
console.log(` No rules directory found.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get section map
|
||||
const sections = parseSections(paths.rulesDir);
|
||||
const sectionMap = generateSectionMap(sections);
|
||||
|
||||
// Get all markdown files (excluding _ prefixed files)
|
||||
const files = readdirSync(paths.rulesDir)
|
||||
.filter((f) => f.endsWith(".md") && !f.startsWith("_"))
|
||||
.map((f) => join(paths.rulesDir, f));
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(` No rule files found.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
let validFiles = 0;
|
||||
let invalidFiles = 0;
|
||||
let hasErrors = false;
|
||||
|
||||
for (const file of files) {
|
||||
const result = validateRuleFile(file, sectionMap, paths.rulesDir);
|
||||
const filename = basename(file);
|
||||
|
||||
if (result.valid) {
|
||||
validFiles++;
|
||||
} else {
|
||||
invalidFiles++;
|
||||
}
|
||||
|
||||
if (!result.valid || result.warnings.length > 0) {
|
||||
console.log(`\n ${filename}:`);
|
||||
|
||||
for (const error of result.errors) {
|
||||
console.log(` ERROR: ${error}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
for (const warning of result.warnings) {
|
||||
console.log(` WARNING: ${warning}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n Total: ${files.length} | Valid: ${validFiles} | Invalid: ${invalidFiles}`,
|
||||
);
|
||||
|
||||
return !hasErrors;
|
||||
}
|
||||
|
||||
// Run validation when executed directly
|
||||
const isMainModule =
|
||||
process.argv[1]?.endsWith("validate.ts") ||
|
||||
process.argv[1]?.endsWith("validate.js");
|
||||
|
||||
if (isMainModule) {
|
||||
const targetSkill = process.argv[2];
|
||||
|
||||
if (targetSkill) {
|
||||
// Validate specific skill
|
||||
if (!validateSkillExists(targetSkill)) {
|
||||
console.error(`Error: Skill "${targetSkill}" not found in skills/`);
|
||||
const available = discoverSkills();
|
||||
if (available.length > 0) {
|
||||
console.error(`Available skills: ${available.join(", ")}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const valid = validateSkill(getSkillPaths(targetSkill));
|
||||
console.log(valid ? "\n✅ Validation passed!" : "\n❌ Validation failed.");
|
||||
process.exit(valid ? 0 : 1);
|
||||
} else {
|
||||
// Validate all skills
|
||||
const skills = discoverSkills();
|
||||
if (skills.length === 0) {
|
||||
console.log("No skills found in skills/ directory.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${skills.length} skill(s): ${skills.join(", ")}\n`);
|
||||
|
||||
let allValid = true;
|
||||
for (const skill of skills) {
|
||||
if (!validateSkill(getSkillPaths(skill))) {
|
||||
allValid = false;
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(
|
||||
allValid ? "✅ All validations passed!" : "❌ Some validations failed.",
|
||||
);
|
||||
process.exit(allValid ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
export { validateSkill };
|
||||
Reference in New Issue
Block a user