claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
454 lines (453 loc) • 15.2 kB
JavaScript
/**
* Skill Validator Service
*
* Validates skill content, schema compliance, and deployment readiness.
* Part of Task 1.1: Automated Skill Deployment Pipeline
*
* @example
* ```typescript
* const result = await validateSkill(dbService, '/path/to/skill');
* if (!result.valid) {
* console.error('Validation failed:', result.errors);
* }
* ```
*/ import * as fs from 'fs';
import * as path from 'path';
import { StandardError, ErrorCode } from '../lib/errors.js';
import { createLogger } from '../lib/logging.js';
import { validateVersion } from './skill-versioning.js';
const logger = createLogger('skill-validator');
/**
* Parse frontmatter from SKILL.md file
*
* @param skillPath - Path to skill directory
* @returns Parsed frontmatter object
* @throws StandardError if frontmatter is invalid or missing
*/ export function parseFrontmatter(skillPath) {
const skillMdPath = path.join(skillPath, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
throw new StandardError(ErrorCode.FILE_NOT_FOUND, `SKILL.md not found in skill directory: ${skillPath}`, {
skillPath,
expectedFile: 'SKILL.md'
});
}
const content = fs.readFileSync(skillMdPath, 'utf-8');
// Extract frontmatter between --- markers
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---/;
const match = content.match(frontmatterRegex);
if (!match) {
throw new StandardError(ErrorCode.PARSE_ERROR, 'No frontmatter found in SKILL.md', {
skillPath,
file: skillMdPath
});
}
try {
// Parse YAML-like frontmatter (simple key: value format)
const frontmatterText = match[1];
const lines = frontmatterText.split('\n').filter((line)=>line.trim());
const frontmatter = {};
for (const line of lines){
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
// Parse arrays (format: [item1, item2, item3])
if (value.startsWith('[') && value.endsWith(']')) {
value = value.substring(1, value.length - 1).split(',').map((v)=>v.trim()).filter((v)=>v);
}
frontmatter[key] = value;
}
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== 'string') {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Frontmatter missing required field: name', {
skillPath,
frontmatter
});
}
if (!frontmatter.version || typeof frontmatter.version !== 'string') {
throw new StandardError(ErrorCode.VALIDATION_FAILED, 'Frontmatter missing required field: version', {
skillPath,
frontmatter
});
}
return frontmatter;
} catch (error) {
if (error instanceof StandardError) {
throw error;
}
throw new StandardError(ErrorCode.PARSE_ERROR, `Failed to parse frontmatter in SKILL.md: ${error.message}`, {
skillPath,
file: skillMdPath
}, error);
}
}
/**
* Validate skill content path exists and has required files
*
* @param skillPath - Path to skill directory
* @returns Validation errors (empty if valid)
*/ export function validateContentPath(skillPath) {
const errors = [];
// Check if directory exists
if (!fs.existsSync(skillPath)) {
errors.push({
code: 'CONTENT_PATH_NOT_FOUND',
message: `Skill directory not found: ${skillPath}`,
context: {
skillPath
}
});
return errors;
}
// Check if it's a directory
const stats = fs.statSync(skillPath);
if (!stats.isDirectory()) {
errors.push({
code: 'CONTENT_PATH_NOT_DIRECTORY',
message: `Skill path is not a directory: ${skillPath}`,
context: {
skillPath
}
});
return errors;
}
// Check for required files
const requiredFiles = [
'SKILL.md',
'execute.sh'
];
for (const file of requiredFiles){
const filePath = path.join(skillPath, file);
if (!fs.existsSync(filePath)) {
errors.push({
code: 'REQUIRED_FILE_MISSING',
message: `Required file missing: ${file}`,
context: {
skillPath,
file,
expectedPath: filePath
}
});
}
}
return errors;
}
/**
* Validate execute.sh is executable
*
* @param skillPath - Path to skill directory
* @returns Validation errors (empty if valid)
*/ export function validateExecuteScript(skillPath) {
const errors = [];
const executePath = path.join(skillPath, 'execute.sh');
if (!fs.existsSync(executePath)) {
// Already caught by validateContentPath
return errors;
}
try {
const stats = fs.statSync(executePath);
// Check if file has execute permission (any execute bit set)
const isExecutable = (stats.mode & 0o111) !== 0;
if (!isExecutable) {
errors.push({
code: 'EXECUTE_SCRIPT_NOT_EXECUTABLE',
message: 'execute.sh is not executable',
context: {
skillPath,
file: executePath,
mode: stats.mode.toString(8)
}
});
}
} catch (error) {
errors.push({
code: 'EXECUTE_SCRIPT_CHECK_FAILED',
message: `Failed to check execute.sh permissions: ${error.message}`,
context: {
skillPath,
file: executePath
}
});
}
return errors;
}
/**
* Validate skill schema compliance (frontmatter structure)
*
* @param skillPath - Path to skill directory
* @returns Validation errors (empty if valid)
*/ export function validateSchemaCompliance(skillPath) {
const errors = [];
try {
const frontmatter = parseFrontmatter(skillPath);
// Validate version format
if (!validateVersion(frontmatter.version)) {
errors.push({
code: 'INVALID_VERSION_FORMAT',
message: `Invalid semantic version format: ${frontmatter.version}`,
context: {
skillPath,
version: frontmatter.version,
expectedFormat: 'x.y.z'
}
});
}
// Validate name format (alphanumeric, hyphens, underscores only)
const nameRegex = /^[a-zA-Z0-9_-]+$/;
if (!nameRegex.test(frontmatter.name)) {
errors.push({
code: 'INVALID_NAME_FORMAT',
message: `Invalid skill name format: ${frontmatter.name}`,
context: {
skillPath,
name: frontmatter.name,
expectedFormat: 'alphanumeric, hyphens, and underscores only'
}
});
}
} catch (error) {
if (error instanceof StandardError) {
errors.push({
code: error.code,
message: error.message,
context: error.context
});
} else {
errors.push({
code: 'SCHEMA_VALIDATION_FAILED',
message: `Schema validation failed: ${error.message}`,
context: {
skillPath
}
});
}
}
return errors;
}
/**
* Check if skill name is unique (no existing skill with same name)
*
* @param dbService - Database service instance
* @param skillName - Name of the skill to check
* @param excludeSkillId - Optional skill ID to exclude from uniqueness check (for updates)
* @returns Validation errors (empty if unique)
*/ export async function validateNameUniqueness(dbService, skillName, excludeSkillId) {
const errors = [];
try {
const adapter = dbService.getAdapter('sqlite');
let query = 'SELECT id, name FROM skills WHERE name = ?';
const params = [
skillName
];
if (excludeSkillId) {
query += ' AND id != ?';
params.push(excludeSkillId);
}
const result = await adapter.query(query, params);
if (result.rows && result.rows.length > 0) {
errors.push({
code: 'NAME_NOT_UNIQUE',
message: `Skill name already exists: ${skillName}`,
context: {
skillName,
existingSkillId: result.rows[0].id
}
});
}
} catch (error) {
errors.push({
code: 'NAME_UNIQUENESS_CHECK_FAILED',
message: `Failed to check name uniqueness: ${error.message}`,
context: {
skillName
}
});
}
return errors;
}
/**
* Check for version conflicts (version already exists for this skill)
*
* @param dbService - Database service instance
* @param skillName - Name of the skill
* @param version - Version to check
* @returns Validation errors (empty if no conflict)
*/ export async function validateVersionConflict(dbService, skillName, version) {
const errors = [];
try {
const adapter = dbService.getAdapter('sqlite');
const result = await adapter.query('SELECT id, version FROM skills WHERE name = ? AND version = ?', [
skillName,
version
]);
if (result.rows && result.rows.length > 0) {
errors.push({
code: 'VERSION_CONFLICT',
message: `Version ${version} already exists for skill: ${skillName}`,
context: {
skillName,
version,
existingSkillId: result.rows[0].id
}
});
}
} catch (error) {
errors.push({
code: 'VERSION_CONFLICT_CHECK_FAILED',
message: `Failed to check version conflict: ${error.message}`,
context: {
skillName,
version
}
});
}
return errors;
}
/**
* Validate tests exist and pass (optional check)
*
* @param skillPath - Path to skill directory
* @returns Validation warnings (not errors, as tests are optional)
*/ export function validateTests(skillPath) {
const warnings = [];
const testPath = path.join(skillPath, 'test.sh');
if (!fs.existsSync(testPath)) {
warnings.push({
code: 'TESTS_NOT_FOUND',
message: 'No test.sh found (tests are recommended but optional)',
context: {
skillPath,
expectedFile: testPath
}
});
return warnings;
}
// Check if test.sh is executable
try {
const stats = fs.statSync(testPath);
const isExecutable = (stats.mode & 0o111) !== 0;
if (!isExecutable) {
warnings.push({
code: 'TEST_SCRIPT_NOT_EXECUTABLE',
message: 'test.sh exists but is not executable',
context: {
skillPath,
file: testPath
}
});
}
} catch (error) {
warnings.push({
code: 'TEST_SCRIPT_CHECK_FAILED',
message: `Failed to check test.sh: ${error.message}`,
context: {
skillPath,
file: testPath
}
});
}
return warnings;
}
/**
* Comprehensive skill validation
*
* Runs all validation checks and returns aggregated results.
*
* @param dbService - Database service instance
* @param skillPath - Path to skill directory
* @param excludeSkillId - Optional skill ID to exclude from uniqueness check
* @returns Validation result with all errors and warnings
*/ export async function validateSkill(dbService, skillPath, excludeSkillId) {
logger.info('Starting skill validation', {
skillPath
});
const errors = [];
const warnings = [];
let metadata = {};
// 1. Validate content path exists
const contentPathErrors = validateContentPath(skillPath);
errors.push(...contentPathErrors);
// If content path is invalid, stop here
if (contentPathErrors.length > 0) {
logger.warn('Skill validation failed: content path invalid', {
skillPath,
errorCount: errors.length
});
return {
valid: false,
errors,
warnings
};
}
// 2. Validate schema compliance
const schemaErrors = validateSchemaCompliance(skillPath);
errors.push(...schemaErrors);
// If schema is invalid, stop here
if (schemaErrors.length > 0) {
logger.warn('Skill validation failed: schema invalid', {
skillPath,
errorCount: errors.length
});
return {
valid: false,
errors,
warnings
};
}
// Parse frontmatter for subsequent checks
let frontmatter;
try {
frontmatter = parseFrontmatter(skillPath);
metadata = {
skillName: frontmatter.name,
version: frontmatter.version,
contentPath: skillPath
};
} catch (error) {
// Already handled in schema validation
logger.warn('Skill validation failed: frontmatter parsing error', {
skillPath,
error: error.message
});
return {
valid: false,
errors,
warnings,
metadata
};
}
// 3. Validate execute.sh is executable
const executeErrors = validateExecuteScript(skillPath);
errors.push(...executeErrors);
// 4. Validate name uniqueness
const nameErrors = await validateNameUniqueness(dbService, frontmatter.name, excludeSkillId);
errors.push(...nameErrors);
// 5. Validate version conflict
const versionErrors = await validateVersionConflict(dbService, frontmatter.name, frontmatter.version);
errors.push(...versionErrors);
// 6. Validate tests (warnings only)
const testWarnings = validateTests(skillPath);
warnings.push(...testWarnings);
const valid = errors.length === 0;
if (valid) {
logger.info('Skill validation passed', {
skillPath,
skillName: frontmatter.name,
version: frontmatter.version,
warningCount: warnings.length
});
} else {
logger.warn('Skill validation failed', {
skillPath,
errorCount: errors.length,
warningCount: warnings.length
});
}
return {
valid,
errors,
warnings,
metadata
};
}
//# sourceMappingURL=skill-validator.js.map